fix: support conversational mode draft updates
This commit is contained in:
@@ -634,6 +634,15 @@
|
|||||||
background: #fff0f2;
|
background: #fff0f2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.assistant-mode-draft-status.superseded {
|
||||||
|
color: #687386;
|
||||||
|
background: #f1f3f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-mode-draft.is-superseded {
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
.assistant-mode-draft.is-saved {
|
.assistant-mode-draft.is-saved {
|
||||||
border-color: #bfe3d4;
|
border-color: #bfe3d4;
|
||||||
}
|
}
|
||||||
@@ -1191,6 +1200,11 @@
|
|||||||
background: var(--status-error-soft);
|
background: var(--status-error-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.assistant-mode-draft-status.superseded {
|
||||||
|
color: var(--assistant-muted);
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.assistant-mode-draft.is-saved {
|
.assistant-mode-draft.is-saved {
|
||||||
border-color: color-mix(in oklch, var(--status-success) 36%, var(--border));
|
border-color: color-mix(in oklch, var(--status-success) 36%, var(--border));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
(function initializeUnifiedAssistant() {
|
(function initializeUnifiedAssistant() {
|
||||||
const STORAGE_KEY = "cognitive-agent.assistant-messages";
|
const STORAGE_KEY = "cognitive-agent.assistant-messages";
|
||||||
const CONVERSATION_STORAGE_KEY = "cognitive-agent.conversation-id";
|
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 MAX_STORED_MESSAGES = 24;
|
||||||
const COMPOSER_MIN_HEIGHT = 64;
|
const COMPOSER_MIN_HEIGHT = 64;
|
||||||
const COMPOSER_MAX_HEIGHT = 116;
|
const COMPOSER_MAX_HEIGHT = 116;
|
||||||
@@ -8,6 +9,7 @@
|
|||||||
let pendingController = null;
|
let pendingController = null;
|
||||||
let pendingMessage = null;
|
let pendingMessage = null;
|
||||||
let latestControlResult = null;
|
let latestControlResult = null;
|
||||||
|
let activeModeDraftContext = null;
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
|
|
||||||
const $ = (selector, root = document) => root.querySelector(selector);
|
const $ = (selector, root = document) => root.querySelector(selector);
|
||||||
@@ -106,6 +108,108 @@
|
|||||||
return created;
|
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() {
|
function loadStoredMessages() {
|
||||||
try {
|
try {
|
||||||
const stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || "[]");
|
const stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || "[]");
|
||||||
@@ -475,6 +579,7 @@
|
|||||||
status.className = "assistant-mode-draft-status success";
|
status.className = "assistant-mode-draft-status success";
|
||||||
status.textContent = existingModeId ? "模式已更新" : "模式已保存";
|
status.textContent = existingModeId ? "模式已更新" : "模式已保存";
|
||||||
card.querySelector(".assistant-mode-draft-actions")?.setAttribute("hidden", "");
|
card.querySelector(".assistant-mode-draft-actions")?.setAttribute("hidden", "");
|
||||||
|
clearActiveModeDraftContext(draft.source_request_id);
|
||||||
await window.CognitiveAgentConsole?.refreshModeProfiles?.(payload.mode_profile?.id);
|
await window.CognitiveAgentConsole?.refreshModeProfiles?.(payload.mode_profile?.id);
|
||||||
appendModeCreatedState(message, payload.mode_profile, { updated: Boolean(existingModeId) });
|
appendModeCreatedState(message, payload.mode_profile, { updated: Boolean(existingModeId) });
|
||||||
persistMessages();
|
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) {
|
function appendModeDraftState(message, draft, actions) {
|
||||||
if (!message?.body || !draft?.name || !draft?.source_request_id) return;
|
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))}"]`;
|
const selector = `[data-assistant-mode-draft="${CSS.escape(String(draft.source_request_id))}"]`;
|
||||||
if ($(selector, message.body)) return;
|
if ($(selector, message.body)) return;
|
||||||
const card = document.createElement("section");
|
const card = document.createElement("section");
|
||||||
card.className = "assistant-mode-draft";
|
card.className = "assistant-mode-draft";
|
||||||
card.dataset.assistantModeDraft = String(draft.source_request_id);
|
card.dataset.assistantModeDraft = String(draft.source_request_id);
|
||||||
|
card.dataset.assistantModeName = normalizeModeName(draft.name);
|
||||||
message.article?.classList.add("assistant-message-mode-draft");
|
message.article?.classList.add("assistant-message-mode-draft");
|
||||||
|
|
||||||
const heading = document.createElement("header");
|
const heading = document.createElement("header");
|
||||||
@@ -613,6 +745,7 @@
|
|||||||
const controlTransport = localStorage.getItem("cognitive-agent.control-transport") || "wired_can";
|
const controlTransport = localStorage.getItem("cognitive-agent.control-transport") || "wired_can";
|
||||||
const hardwareClientId = localStorage.getItem("cognitive-agent.hardware-client-id");
|
const hardwareClientId = localStorage.getItem("cognitive-agent.hardware-client-id");
|
||||||
const vehicleId = $("#vehicleSelect")?.value || null;
|
const vehicleId = $("#vehicleSelect")?.value || null;
|
||||||
|
const modeProfileDraftContext = requestModeDraftContext(message);
|
||||||
return {
|
return {
|
||||||
message,
|
message,
|
||||||
conversation_id: conversationId(),
|
conversation_id: conversationId(),
|
||||||
@@ -620,6 +753,9 @@
|
|||||||
execution_mode: executionMode,
|
execution_mode: executionMode,
|
||||||
control_transport: controlTransport,
|
control_transport: controlTransport,
|
||||||
...(vehicleId ? { vehicle_id: vehicleId } : {}),
|
...(vehicleId ? { vehicle_id: vehicleId } : {}),
|
||||||
|
...(modeProfileDraftContext
|
||||||
|
? { mode_profile_draft_context: modeProfileDraftContext }
|
||||||
|
: {}),
|
||||||
...(executionMode === "hardware" && controlTransport !== "wireless_mqtt" && hardwareClientId
|
...(executionMode === "hardware" && controlTransport !== "wireless_mqtt" && hardwareClientId
|
||||||
? { hardware_client_id: hardwareClientId }
|
? { hardware_client_id: hardwareClientId }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -681,6 +817,7 @@
|
|||||||
event.payload.mode_profile
|
event.payload.mode_profile
|
||||||
&& event.payload.orchestration_route === "mode_profile_create"
|
&& event.payload.orchestration_route === "mode_profile_create"
|
||||||
) {
|
) {
|
||||||
|
clearActiveModeDraftContext();
|
||||||
appendModeCreatedState(message, event.payload.mode_profile);
|
appendModeCreatedState(message, event.payload.mode_profile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -859,6 +996,7 @@
|
|||||||
resizeComposerInput(input);
|
resizeComposerInput(input);
|
||||||
}, 300);
|
}, 300);
|
||||||
renderInitialMessages();
|
renderInitialMessages();
|
||||||
|
activeModeDraftContext = loadActiveModeDraftContext();
|
||||||
setComposerPending(false);
|
setComposerPending(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -873,6 +1011,7 @@
|
|||||||
const target = event.target instanceof Element ? event.target : null;
|
const target = event.target instanceof Element ? event.target : null;
|
||||||
if (!target?.closest("#newConversation")) return;
|
if (!target?.closest("#newConversation")) return;
|
||||||
sessionStorage.removeItem(STORAGE_KEY);
|
sessionStorage.removeItem(STORAGE_KEY);
|
||||||
|
clearActiveModeDraftContext();
|
||||||
sessionStorage.setItem(CONVERSATION_STORAGE_KEY, createConversationId());
|
sessionStorage.setItem(CONVERSATION_STORAGE_KEY, createConversationId());
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<link rel="stylesheet" href="/console-v2.css?v=delivery-state-65" />
|
<link rel="stylesheet" href="/console-v2.css?v=delivery-state-65" />
|
||||||
<link rel="stylesheet" href="/self-extension.css?v=gscu-tcp-43" />
|
<link rel="stylesheet" href="/self-extension.css?v=gscu-tcp-43" />
|
||||||
<link rel="stylesheet" href="/speech-input.css?v=sensevoice-7" />
|
<link rel="stylesheet" href="/speech-input.css?v=sensevoice-7" />
|
||||||
<link rel="stylesheet" href="/assistant-chat.css?v=codex-assistant-22" />
|
<link rel="stylesheet" href="/assistant-chat.css?v=codex-assistant-23" />
|
||||||
<link rel="stylesheet" href="/energy-monitor.css?v=energy-monitor-4" />
|
<link rel="stylesheet" href="/energy-monitor.css?v=energy-monitor-4" />
|
||||||
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
|
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
|
||||||
<script src="/vendor/three/0.147.0/three.min.js" defer></script>
|
<script src="/vendor/three/0.147.0/three.min.js" defer></script>
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
<script src="/pwa.js?v=android-pad-3" defer></script>
|
<script src="/pwa.js?v=android-pad-3" defer></script>
|
||||||
<script src="/console-v2.js?v=delivery-state-62" defer></script>
|
<script src="/console-v2.js?v=delivery-state-62" defer></script>
|
||||||
<script src="/self-extension.js?v=gscu-tcp-43" defer></script>
|
<script src="/self-extension.js?v=gscu-tcp-43" defer></script>
|
||||||
<script src="/assistant-chat.js?v=codex-assistant-22" defer></script>
|
<script src="/assistant-chat.js?v=codex-assistant-23" defer></script>
|
||||||
<script src="/energy-monitor.js?v=energy-monitor-4" defer></script>
|
<script src="/energy-monitor.js?v=energy-monitor-4" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="console-v2">
|
<body class="console-v2">
|
||||||
|
|||||||
@@ -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 SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||||
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
|
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
|
||||||
const APP_SHELL = [
|
const APP_SHELL = [
|
||||||
@@ -16,8 +16,8 @@ const APP_SHELL = [
|
|||||||
"/speech-input.css?v=sensevoice-7",
|
"/speech-input.css?v=sensevoice-7",
|
||||||
"/speech-input.js?v=sensevoice-12",
|
"/speech-input.js?v=sensevoice-12",
|
||||||
"/speech-audio-worklet.js?v=sensevoice-5",
|
"/speech-audio-worklet.js?v=sensevoice-5",
|
||||||
"/assistant-chat.css?v=codex-assistant-22",
|
"/assistant-chat.css?v=codex-assistant-23",
|
||||||
"/assistant-chat.js?v=codex-assistant-22",
|
"/assistant-chat.js?v=codex-assistant-23",
|
||||||
"/energy-monitor.css?v=energy-monitor-4",
|
"/energy-monitor.css?v=energy-monitor-4",
|
||||||
"/energy-monitor.js?v=energy-monitor-4",
|
"/energy-monitor.js?v=energy-monitor-4",
|
||||||
"/assets/pets/self-extension-cat.png?v=3",
|
"/assets/pets/self-extension-cat.png?v=3",
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ operations:
|
|||||||
target_scope: passenger
|
target_scope: passenger
|
||||||
operation: set
|
operation: set
|
||||||
parameters:
|
parameters:
|
||||||
temperature_c: 24.5
|
temperature_c: 25
|
||||||
reason: 办公座位专属恒温 24.5 度。
|
reason: 办公座位专属恒温 25 度,使用当前 CAN 协议支持的整数温度。
|
||||||
- domain: climate
|
- domain: climate
|
||||||
capability: climate.set_fan_level
|
capability: climate.set_fan_level
|
||||||
target_scope: vehicle
|
target_scope: vehicle
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ operations:
|
|||||||
target_scope: passenger
|
target_scope: passenger
|
||||||
operation: set
|
operation: set
|
||||||
parameters:
|
parameters:
|
||||||
temperature_c: 23.5
|
temperature_c: 24
|
||||||
reason: 副驾恒温锁定 23.5 度。
|
reason: 副驾恒温锁定 24 度,使用当前 CAN 协议支持的整数温度。
|
||||||
- domain: climate
|
- domain: climate
|
||||||
capability: climate.set_fan_level
|
capability: climate.set_fan_level
|
||||||
target_scope: vehicle
|
target_scope: vehicle
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ from dataclasses import dataclass, field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Protocol
|
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__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
INTERNAL_SCENE_BUILDER_MARKER = "根据该场景名称使用当前可控原子能力"
|
INTERNAL_SCENE_BUILDER_MARKER = "根据该场景名称使用当前可控原子能力"
|
||||||
|
|
||||||
@@ -145,6 +151,7 @@ _MODE_PROFILE_CREATE_PATTERN = re.compile(
|
|||||||
r"(?:配置)?(?:并(?:添加|加入)(?:到)?功能模式(?:栏)?)?"
|
r"(?:配置)?(?:并(?:添加|加入)(?:到)?功能模式(?:栏)?)?"
|
||||||
r"(?:[,,::](?P<instruction>.{1,800}))?[。.!!]?$"
|
r"(?:[,,::](?P<instruction>.{1,800}))?[。.!!]?$"
|
||||||
)
|
)
|
||||||
|
_MODE_EDITOR_REQUEST_ID_PATTERN = re.compile(r"^req-[a-z0-9-]{6,100}$")
|
||||||
_HOT_DISCOMFORT_PATTERN = re.compile(r"热|闷热|好热|太晒")
|
_HOT_DISCOMFORT_PATTERN = re.compile(r"热|闷热|好热|太晒")
|
||||||
_COLD_DISCOMFORT_PATTERN = re.compile(r"冷|好冷")
|
_COLD_DISCOMFORT_PATTERN = re.compile(r"冷|好冷")
|
||||||
_IMPLICIT_HOT_REQUEST_PATTERN = re.compile(
|
_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
|
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(
|
def _catalog_exact_mode_match_count(
|
||||||
catalog: Mapping[str, object],
|
catalog: Mapping[str, object],
|
||||||
requested_name: str,
|
requested_name: str,
|
||||||
@@ -314,13 +374,32 @@ def _vehicle_action_state(result: Mapping[str, object]) -> tuple[str, bool, str]
|
|||||||
def _authorize_vehicle_request(
|
def _authorize_vehicle_request(
|
||||||
message: str,
|
message: str,
|
||||||
control_terms: Sequence[str] = (),
|
control_terms: Sequence[str] = (),
|
||||||
|
mode_profile_draft_context: Mapping[str, object] | None = None,
|
||||||
) -> _VehicleAuthorization:
|
) -> _VehicleAuthorization:
|
||||||
"""Create a server-owned capability envelope from the original user text."""
|
"""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())
|
normalized = "".join(str(message).lower().split())
|
||||||
if _DENIAL_PATTERN.search(normalized):
|
if _DENIAL_PATTERN.search(normalized):
|
||||||
return _VehicleAuthorization(None, "denied_by_user")
|
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)
|
implicit_request = re.sub(r"[,。!?!?、]", "", normalized)
|
||||||
if not _KNOWLEDGE_PATTERN.search(normalized):
|
if not _KNOWLEDGE_PATTERN.search(normalized):
|
||||||
if _IMPLICIT_HOT_REQUEST_PATTERN.fullmatch(implicit_request):
|
if _IMPLICIT_HOT_REQUEST_PATTERN.fullmatch(implicit_request):
|
||||||
@@ -342,6 +421,12 @@ def _authorize_vehicle_request(
|
|||||||
# user as a saveable draft.
|
# user as a saveable draft.
|
||||||
return _VehicleAuthorization(message, "explicit_mode_profile_create")
|
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 = {
|
normalized_terms = {
|
||||||
"".join(str(term).lower().split()) for term in control_terms if str(term).strip()
|
"".join(str(term).lower().split()) for term in control_terms if str(term).strip()
|
||||||
}
|
}
|
||||||
@@ -453,6 +538,7 @@ class CodexAssistantService:
|
|||||||
control_terms: Sequence[str] = (),
|
control_terms: Sequence[str] = (),
|
||||||
query_capabilities: CapabilityQuery | None = None,
|
query_capabilities: CapabilityQuery | None = None,
|
||||||
authoritative_answer: str | None = None,
|
authoritative_answer: str | None = None,
|
||||||
|
mode_profile_draft_context: Mapping[str, object] | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
del history # Codex owns conversation history after the thread is created.
|
del history # Codex owns conversation history after the thread is created.
|
||||||
message = " ".join(str(message).strip().split())
|
message = " ".join(str(message).strip().split())
|
||||||
@@ -472,11 +558,22 @@ class CodexAssistantService:
|
|||||||
authoritative_answer = " ".join(str(authoritative_answer or "").strip().split())
|
authoritative_answer = " ".join(str(authoritative_answer or "").strip().split())
|
||||||
if len(authoritative_answer) > 16_000:
|
if len(authoritative_answer) > 16_000:
|
||||||
raise ValueError("authoritative_answer must not exceed 16000 characters")
|
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 = (
|
fast_comfort_control = (
|
||||||
self._fast_implicit_controls and authorization.reason in _FAST_COMFORT_REASONS
|
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
|
mode_parts = _mode_profile_create_parts(message) if mode_profile_preview else None
|
||||||
exact_mode_matches = (
|
exact_mode_matches = (
|
||||||
_catalog_exact_mode_match_count(catalog, mode_parts[0])
|
_catalog_exact_mode_match_count(catalog, mode_parts[0])
|
||||||
@@ -493,11 +590,18 @@ class CodexAssistantService:
|
|||||||
or exact_mode_matches != 0
|
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
|
# Explicit device settings and repository-backed scenes remain on the
|
||||||
# zero-model-latency path. A genuinely new name-only scene needs Codex
|
# zero-model-latency path. A genuinely new name-only scene needs Codex
|
||||||
# to compose a candidate plan from the live capability catalog; the
|
# to compose a candidate plan from the live capability catalog; the
|
||||||
# vehicle service still limits it to dry-run and requires confirmation.
|
# 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 = (
|
route = (
|
||||||
"capability_catalog"
|
"capability_catalog"
|
||||||
if authoritative_answer
|
if authoritative_answer
|
||||||
@@ -509,7 +613,7 @@ class CodexAssistantService:
|
|||||||
"n7web-capability-registry"
|
"n7web-capability-registry"
|
||||||
if authoritative_answer
|
if authoritative_answer
|
||||||
else "n7web-mode-builder"
|
else "n7web-mode-builder"
|
||||||
if direct_mode_profile_preview
|
if direct_mode_profile_preview or direct_mode_profile_update
|
||||||
else "n7web-control-router"
|
else "n7web-control-router"
|
||||||
if fast_comfort_control
|
if fast_comfort_control
|
||||||
else "codex-app-server"
|
else "codex-app-server"
|
||||||
@@ -538,7 +642,7 @@ class CodexAssistantService:
|
|||||||
"正在读取工程能力目录……"
|
"正在读取工程能力目录……"
|
||||||
if authoritative_answer
|
if authoritative_answer
|
||||||
else "正在构建并校验功能模式……"
|
else "正在构建并校验功能模式……"
|
||||||
if direct_mode_profile_preview
|
if direct_mode_profile_preview or direct_mode_profile_update
|
||||||
else "Codex 正在理解场景并组合工程能力……"
|
else "Codex 正在理解场景并组合工程能力……"
|
||||||
if mode_profile_preview
|
if mode_profile_preview
|
||||||
else "正在快速处理座舱舒适需求……"
|
else "正在快速处理座舱舒适需求……"
|
||||||
@@ -622,7 +726,13 @@ class CodexAssistantService:
|
|||||||
instruction = authorization.instruction
|
instruction = authorization.instruction
|
||||||
if instruction is None:
|
if instruction is None:
|
||||||
raise CodexAssistantError("direct vehicle control was not authorized")
|
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 (
|
if (
|
||||||
emit(
|
emit(
|
||||||
{
|
{
|
||||||
@@ -630,7 +740,9 @@ class CodexAssistantService:
|
|||||||
"message_id": message_id,
|
"message_id": message_id,
|
||||||
"display_text": (
|
"display_text": (
|
||||||
"正在生成模式动作预览……"
|
"正在生成模式动作预览……"
|
||||||
if mode_profile_preview
|
if mode_profile_preview and not mode_profile_draft_update
|
||||||
|
else "正在调整待保存模式动作……"
|
||||||
|
if mode_profile_draft_update
|
||||||
else "正在通过车辆安全链执行舒适性调节……"
|
else "正在通过车辆安全链执行舒适性调节……"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -854,6 +966,28 @@ class CodexAssistantService:
|
|||||||
if isinstance(draft, Mapping)
|
if isinstance(draft, Mapping)
|
||||||
else "新功能模式"
|
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"):
|
if isinstance(draft, Mapping) and draft.get("existing_mode_id"):
|
||||||
raw_operation_count = draft.get("operation_count")
|
raw_operation_count = draft.get("operation_count")
|
||||||
operation_count = (
|
operation_count = (
|
||||||
@@ -1614,6 +1748,10 @@ class CodexAssistantService:
|
|||||||
"status": str(result.get("status") or "unknown"),
|
"status": str(result.get("status") or "unknown"),
|
||||||
"execution_mode": str(result.get("execution_mode") or "dry_run"),
|
"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"):
|
for key in ("request_id", "utterance", "decision_summary"):
|
||||||
if result.get(key) is not None:
|
if result.get(key) is not None:
|
||||||
safe[key] = CodexAssistantService._safe_result_value(
|
safe[key] = CodexAssistantService._safe_result_value(
|
||||||
@@ -1699,6 +1837,8 @@ class CodexAssistantService:
|
|||||||
"existing_mode_id",
|
"existing_mode_id",
|
||||||
"existing_mode_revision",
|
"existing_mode_revision",
|
||||||
"generation_source",
|
"generation_source",
|
||||||
|
"draft_revision",
|
||||||
|
"revises_source_request_id",
|
||||||
)
|
)
|
||||||
if key in mode_profile_draft
|
if key in mode_profile_draft
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<name>[^,。,.!!::\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<change>.*?)[。.!!]?$"
|
||||||
|
)
|
||||||
|
_BA_UPDATE = re.compile(
|
||||||
|
rf"^{_LEADING_POLITE}(?:把|将){_MODE_NAME}"
|
||||||
|
rf"{_MODE_MEMBER_BOUNDARY}[,,::]?"
|
||||||
|
r"(?P<change>.+?)[。.!!]?$"
|
||||||
|
)
|
||||||
|
_NAME_FIRST_UPDATE = re.compile(
|
||||||
|
rf"^{_LEADING_POLITE}{_MODE_NAME}"
|
||||||
|
rf"{_MODE_POSSESSIVE_BOUNDARY}[,,::]?"
|
||||||
|
r"(?P<change>.+?)[。.!!]?$"
|
||||||
|
)
|
||||||
|
_CONTEXTUAL_NAME_FIRST_UPDATE = re.compile(
|
||||||
|
rf"^{_LEADING_POLITE}{_MODE_NAME}[,,::]"
|
||||||
|
r"(?P<change>.+?)[。.!!]?$"
|
||||||
|
)
|
||||||
|
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
|
||||||
@@ -74,6 +74,11 @@ from cognitive_agent.application.semantic_ir import ( # noqa: E402
|
|||||||
apply_language_safety_guards,
|
apply_language_safety_guards,
|
||||||
resolve_explicit_seat_scopes,
|
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
|
from cognitive_agent.application.native_can_feedback import ( # noqa: E402
|
||||||
VehicleSafetyPreflightError,
|
VehicleSafetyPreflightError,
|
||||||
authoritative_physical_ch1_feedback_state,
|
authoritative_physical_ch1_feedback_state,
|
||||||
@@ -82,6 +87,7 @@ from cognitive_agent.application.native_can_feedback import ( # noqa: E402
|
|||||||
require_stationary_vehicle,
|
require_stationary_vehicle,
|
||||||
)
|
)
|
||||||
from cognitive_agent.application.native_can_runtime import ( # noqa: E402
|
from cognitive_agent.application.native_can_runtime import ( # noqa: E402
|
||||||
|
NativeCanCompileError,
|
||||||
NativeCanExecutor,
|
NativeCanExecutor,
|
||||||
NativeCanRuntime,
|
NativeCanRuntime,
|
||||||
UNVERIFIED_NATIVE_CAPABILITIES,
|
UNVERIFIED_NATIVE_CAPABILITIES,
|
||||||
@@ -297,16 +303,6 @@ CONTROL_CLAUSE_PREDICATE_PATTERN = re.compile(
|
|||||||
r"(?:\d+(?:\.\d+)?|[零一二三四五六七八九十百两半]+)"
|
r"(?:\d+(?:\.\d+)?|[零一二三四五六七八九十百两半]+)"
|
||||||
r"(?:%|%|度|℃|档|级|秒|毫秒)"
|
r"(?:%|%|度|℃|档|级|秒|毫秒)"
|
||||||
)
|
)
|
||||||
UPDATE_MODE_PROFILE_PREFIX = re.compile(
|
|
||||||
r"^(?:请(?:帮我)?|帮我)?(?:修改一下|修改|调整|更改|更新|改一下)"
|
|
||||||
r"(?P<name>[^,。,.!!\s]{1,64}?模式)"
|
|
||||||
r"(?:[,。,.!!\s]*(?P<change>.*))?$"
|
|
||||||
)
|
|
||||||
UPDATE_MODE_PROFILE_BA = re.compile(
|
|
||||||
r"^(?:请(?:帮我)?|帮我)?(?:把|将)"
|
|
||||||
r"(?P<name>[^,。,.!!\s]{1,64}?模式)"
|
|
||||||
r"的(?P<change>.+)$"
|
|
||||||
)
|
|
||||||
RESTORE_MODE_PROFILE_PREFIX = re.compile(
|
RESTORE_MODE_PROFILE_PREFIX = re.compile(
|
||||||
r"^(?:请(?:帮我)?|帮我)?(?:恢复|还原|重置|初始化)"
|
r"^(?:请(?:帮我)?|帮我)?(?:恢复|还原|重置|初始化)"
|
||||||
r"(?P<name>[^,。,.!!\s]{1,64}?模式)"
|
r"(?P<name>[^,。,.!!\s]{1,64}?模式)"
|
||||||
@@ -1515,6 +1511,7 @@ class SeatDemoService:
|
|||||||
control_transport: str = WIRED_CAN_TRANSPORT,
|
control_transport: str = WIRED_CAN_TRANSPORT,
|
||||||
on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
|
on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
|
||||||
assistant_plan: dict[str, Any] | None = None,
|
assistant_plan: dict[str, Any] | None = None,
|
||||||
|
mode_profile_draft_context: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Validate and execute deterministic controls before hosted parsing."""
|
"""Validate and execute deterministic controls before hosted parsing."""
|
||||||
|
|
||||||
@@ -1545,6 +1542,7 @@ class SeatDemoService:
|
|||||||
control_transport=control_transport,
|
control_transport=control_transport,
|
||||||
_on_commands_validated=on_commands_validated,
|
_on_commands_validated=on_commands_validated,
|
||||||
_assistant_plan=assistant_plan,
|
_assistant_plan=assistant_plan,
|
||||||
|
_mode_profile_draft_context=mode_profile_draft_context,
|
||||||
)
|
)
|
||||||
result.update(
|
result.update(
|
||||||
{
|
{
|
||||||
@@ -1602,6 +1600,7 @@ class SeatDemoService:
|
|||||||
_allow_empty_control_result=True,
|
_allow_empty_control_result=True,
|
||||||
_on_commands_validated=publish_local_commands,
|
_on_commands_validated=publish_local_commands,
|
||||||
_assistant_plan=assistant_plan,
|
_assistant_plan=assistant_plan,
|
||||||
|
_mode_profile_draft_context=mode_profile_draft_context,
|
||||||
)
|
)
|
||||||
elapsed_ms = round((time.perf_counter() - started) * 1000, 3)
|
elapsed_ms = round((time.perf_counter() - started) * 1000, 3)
|
||||||
supplement_required = bool(
|
supplement_required = bool(
|
||||||
@@ -2048,13 +2047,37 @@ class SeatDemoService:
|
|||||||
_direct_mode_profile_id: str | None = None,
|
_direct_mode_profile_id: str | None = None,
|
||||||
_speech_understanding: dict[str, Any] | None = None,
|
_speech_understanding: dict[str, Any] | None = None,
|
||||||
_assistant_plan: dict[str, Any] | None = None,
|
_assistant_plan: dict[str, Any] | None = None,
|
||||||
|
_mode_profile_draft_context: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
control_transport = self._validate_control_transport(control_transport)
|
control_transport = self._validate_control_transport(control_transport)
|
||||||
# Creating a reusable scene is a configuration workflow. Synthesize
|
# Creating a reusable scene is a configuration workflow. Synthesize
|
||||||
# and validate it in dry-run even when the console currently targets
|
# and validate it in dry-run even when the console currently targets
|
||||||
# the real bench, otherwise virtual domains such as climate or ambient
|
# the real bench, otherwise virtual domains such as climate or ambient
|
||||||
# light would be rejected before the profile can be persisted.
|
# 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"
|
execution_mode = "dry_run"
|
||||||
if execution_mode != "hardware":
|
if execution_mode != "hardware":
|
||||||
return self._process_once(
|
return self._process_once(
|
||||||
@@ -2076,6 +2099,7 @@ class SeatDemoService:
|
|||||||
_direct_mode_profile_id,
|
_direct_mode_profile_id,
|
||||||
_speech_understanding,
|
_speech_understanding,
|
||||||
assistant_plan=_assistant_plan,
|
assistant_plan=_assistant_plan,
|
||||||
|
mode_profile_draft_context=_mode_profile_draft_context,
|
||||||
)
|
)
|
||||||
if control_transport == WIRELESS_MQTT_TRANSPORT:
|
if control_transport == WIRELESS_MQTT_TRANSPORT:
|
||||||
# The head unit owns both physical CAN channels in wireless mode.
|
# The head unit owns both physical CAN channels in wireless mode.
|
||||||
@@ -2100,6 +2124,7 @@ class SeatDemoService:
|
|||||||
_direct_mode_profile_id,
|
_direct_mode_profile_id,
|
||||||
_speech_understanding,
|
_speech_understanding,
|
||||||
assistant_plan=_assistant_plan,
|
assistant_plan=_assistant_plan,
|
||||||
|
mode_profile_draft_context=_mode_profile_draft_context,
|
||||||
)
|
)
|
||||||
if hardware_access_bypass or self._driver_only_vehicle_execution_active(
|
if hardware_access_bypass or self._driver_only_vehicle_execution_active(
|
||||||
control_transport
|
control_transport
|
||||||
@@ -2124,6 +2149,7 @@ class SeatDemoService:
|
|||||||
_direct_mode_profile_id,
|
_direct_mode_profile_id,
|
||||||
_speech_understanding,
|
_speech_understanding,
|
||||||
assistant_plan=_assistant_plan,
|
assistant_plan=_assistant_plan,
|
||||||
|
mode_profile_draft_context=_mode_profile_draft_context,
|
||||||
)
|
)
|
||||||
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
|
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
|
||||||
with self.hardware_execution.reserve():
|
with self.hardware_execution.reserve():
|
||||||
@@ -2147,6 +2173,7 @@ class SeatDemoService:
|
|||||||
_direct_mode_profile_id,
|
_direct_mode_profile_id,
|
||||||
_speech_understanding,
|
_speech_understanding,
|
||||||
assistant_plan=_assistant_plan,
|
assistant_plan=_assistant_plan,
|
||||||
|
mode_profile_draft_context=_mode_profile_draft_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _process_once(
|
def _process_once(
|
||||||
@@ -2171,6 +2198,7 @@ class SeatDemoService:
|
|||||||
assistant_plan: dict[str, Any] | None = None,
|
assistant_plan: dict[str, Any] | None = None,
|
||||||
allow_fuzzy_mode_profile_match: bool = True,
|
allow_fuzzy_mode_profile_match: bool = True,
|
||||||
allow_scene_composition: bool = False,
|
allow_scene_composition: bool = False,
|
||||||
|
mode_profile_draft_context: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
control_transport = self._validate_control_transport(control_transport)
|
control_transport = self._validate_control_transport(control_transport)
|
||||||
utterance = utterance.strip()
|
utterance = utterance.strip()
|
||||||
@@ -2203,11 +2231,24 @@ class SeatDemoService:
|
|||||||
mode_save_name = self._parse_mode_profile_save_name(utterance)
|
mode_save_name = self._parse_mode_profile_save_name(utterance)
|
||||||
mode_create_name = self._parse_mode_profile_create_name(utterance)
|
mode_create_name = self._parse_mode_profile_create_name(utterance)
|
||||||
mode_restore_name = self._parse_mode_profile_restore_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 = (
|
control_plane_request = (
|
||||||
mode_save_name is not None
|
mode_save_name is not None
|
||||||
or mode_restore_name is not None
|
or mode_restore_name is not None
|
||||||
or mode_update is not None
|
or mode_update is not None
|
||||||
|
or mode_profile_query
|
||||||
)
|
)
|
||||||
hardware_request = execution_mode == "hardware"
|
hardware_request = execution_mode == "hardware"
|
||||||
if hardware_request and _has_mixed_driver_passenger_seat_scope(utterance):
|
if hardware_request and _has_mixed_driver_passenger_seat_scope(utterance):
|
||||||
@@ -2242,7 +2283,12 @@ class SeatDemoService:
|
|||||||
assistant_plan=assistant_plan,
|
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(
|
context = self._context_for_vehicle(
|
||||||
self.conversations.snapshot(conversation_id),
|
self.conversations.snapshot(conversation_id),
|
||||||
vehicle_profile,
|
vehicle_profile,
|
||||||
@@ -2319,6 +2365,24 @@ class SeatDemoService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
started = time.perf_counter()
|
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:
|
if mode_save_name is not None:
|
||||||
return self._process_mode_profile_save(
|
return self._process_mode_profile_save(
|
||||||
utterance,
|
utterance,
|
||||||
@@ -2342,6 +2406,28 @@ class SeatDemoService:
|
|||||||
)
|
)
|
||||||
if mode_update is not None:
|
if mode_update is not None:
|
||||||
mode_name, change_text = mode_update
|
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(
|
return self._process_mode_profile_update(
|
||||||
utterance,
|
utterance,
|
||||||
request_id,
|
request_id,
|
||||||
@@ -3550,18 +3636,7 @@ class SeatDemoService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_mode_profile_update(utterance: str) -> tuple[str, str] | None:
|
def _parse_mode_profile_update(utterance: str) -> tuple[str, str] | None:
|
||||||
explicit = UPDATE_MODE_PROFILE_PREFIX.search(utterance)
|
return parse_mode_profile_update(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
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_mode_profile_restore_name(utterance: str) -> str | None:
|
def _parse_mode_profile_restore_name(utterance: str) -> str | None:
|
||||||
@@ -3953,6 +4028,414 @@ class SeatDemoService:
|
|||||||
domains.append(domain)
|
domains.append(domain)
|
||||||
return domains or ["seat"]
|
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(
|
def _process_mode_profile_update(
|
||||||
self,
|
self,
|
||||||
utterance: str,
|
utterance: str,
|
||||||
@@ -4303,10 +4786,11 @@ class SeatDemoService:
|
|||||||
traces: list[dict[str, Any]],
|
traces: list[dict[str, Any]],
|
||||||
request_id: str,
|
request_id: str,
|
||||||
) -> ModeProfile | None:
|
) -> ModeProfile | None:
|
||||||
matches = self.mode_profiles.match_text(
|
exact = self.mode_profiles.resolve_exact_name(
|
||||||
mode_name,
|
mode_name,
|
||||||
vehicle_id=vehicle_profile.id,
|
vehicle_id=vehicle_profile.id,
|
||||||
)
|
)
|
||||||
|
matches = [exact] if exact is not None else []
|
||||||
result["matched_modes"] = [profile.public_dict() for profile in matches]
|
result["matched_modes"] = [profile.public_dict() for profile in matches]
|
||||||
if len(matches) == 1:
|
if len(matches) == 1:
|
||||||
return matches[0]
|
return matches[0]
|
||||||
@@ -4614,6 +5098,7 @@ class SeatDemoService:
|
|||||||
mode_name: str,
|
mode_name: str,
|
||||||
control_transport: str,
|
control_transport: str,
|
||||||
assistant_plan: dict[str, Any] | None = None,
|
assistant_plan: dict[str, Any] | None = None,
|
||||||
|
source_draft: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Build and verify a named-mode draft for explicit user confirmation.
|
"""Build and verify a named-mode draft for explicit user confirmation.
|
||||||
|
|
||||||
@@ -4629,14 +5114,42 @@ class SeatDemoService:
|
|||||||
if create_match is not None
|
if create_match is not None
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
existing_profile = self.mode_profiles.resolve_exact_name(
|
source_existing_mode_id = (
|
||||||
mode_name,
|
str(source_draft.get("existing_mode_id") or "").strip()
|
||||||
vehicle_id=vehicle_profile.id,
|
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 = (
|
generation_source = (
|
||||||
"registered_mode_profile"
|
"draft_revision"
|
||||||
|
if source_draft is not None
|
||||||
|
else "registered_mode_profile"
|
||||||
if source_profile is not None
|
if source_profile is not None
|
||||||
else "codex_scene_composition"
|
else "codex_scene_composition"
|
||||||
if assistant_plan is not None and not requested_instruction
|
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
|
# 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
|
# being parsed as a second control clause on the assistant's local fast
|
||||||
# path, turning a valid nine-action mode into an incomplete draft.
|
# path, turning a valid nine-action mode into an incomplete draft.
|
||||||
source_utterance = requested_instruction or f"进入{mode_name}"
|
source_utterance = requested_instruction or f"进入{draft_mode_name}"
|
||||||
source_result = self._process_once(
|
try:
|
||||||
source_utterance,
|
source_result = self._process_once(
|
||||||
provider,
|
source_utterance,
|
||||||
conversation_id,
|
provider,
|
||||||
"dry_run",
|
conversation_id,
|
||||||
approval_token,
|
"dry_run",
|
||||||
vehicle_profile.id,
|
approval_token,
|
||||||
simulation_fault,
|
vehicle_profile.id,
|
||||||
control_transport=control_transport,
|
simulation_fault,
|
||||||
direct_mode_profile_id=(
|
control_transport=control_transport,
|
||||||
source_profile.id if source_profile is not None else None
|
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
|
assistant_plan=assistant_plan,
|
||||||
# scene. It must not fall back to substring matching (for example
|
# ``source_utterance`` is synthesized for a name-only, unknown
|
||||||
# ``我的小憩模式`` -> ``小憩模式``), otherwise the exact resolver's
|
# scene. It must not fall back to substring matching (for example
|
||||||
# ambiguity and collision checks can be bypassed.
|
# ``我的小憩模式`` -> ``小憩模式``), otherwise the exact resolver's
|
||||||
allow_fuzzy_mode_profile_match=bool(requested_instruction),
|
# ambiguity and collision checks can be bypassed.
|
||||||
allow_scene_composition=(
|
allow_fuzzy_mode_profile_match=bool(requested_instruction),
|
||||||
assistant_plan is not None
|
allow_scene_composition=(
|
||||||
and source_profile is None
|
assistant_plan is not None
|
||||||
and not requested_instruction
|
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(
|
draft_context = self._context_for_vehicle(
|
||||||
self.conversations.snapshot(conversation_id),
|
self.conversations.snapshot(conversation_id),
|
||||||
vehicle_profile,
|
vehicle_profile,
|
||||||
@@ -4736,7 +5317,7 @@ class SeatDemoService:
|
|||||||
] if isinstance(source_actions, list) else []
|
] if isinstance(source_actions, list) else []
|
||||||
if (
|
if (
|
||||||
source_result.get("status") == "succeeded"
|
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 assistant_plan is not None
|
||||||
and (
|
and (
|
||||||
self._scene_plan_signatures(assistant_plan)
|
self._scene_plan_signatures(assistant_plan)
|
||||||
@@ -4762,7 +5343,7 @@ class SeatDemoService:
|
|||||||
]
|
]
|
||||||
|
|
||||||
if source_result.get("status") != "succeeded":
|
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
|
# Final fail-closed barrier for errors discovered after the
|
||||||
# plan guard (adapter, schema, compiler, or safety validation).
|
# plan guard (adapter, schema, compiler, or safety validation).
|
||||||
# A failed reusable scene must not expose a trial-able subset.
|
# 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_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"] = {
|
source_result["mode_profile_draft"] = {
|
||||||
"name": draft_mode_name,
|
"name": draft_mode_name,
|
||||||
"instruction": source_utterance,
|
"instruction": source_utterance,
|
||||||
@@ -4832,17 +5447,18 @@ class SeatDemoService:
|
|||||||
"vehicle_id": vehicle_profile.id,
|
"vehicle_id": vehicle_profile.id,
|
||||||
"domains": domains,
|
"domains": domains,
|
||||||
"operation_count": len(reusable_actions),
|
"operation_count": len(reusable_actions),
|
||||||
|
**existing_binding,
|
||||||
|
"generation_source": generation_source,
|
||||||
|
"draft_revision": source_revision + 1,
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
"existing_mode_id": existing_profile.id,
|
"revises_source_request_id": str(
|
||||||
"existing_mode_revision": (
|
source_draft.get("source_request_id") or ""
|
||||||
self.mode_profiles.profile_revision(existing_profile)
|
).strip()
|
||||||
),
|
|
||||||
}
|
}
|
||||||
if existing_profile is not None
|
if isinstance(source_draft, dict)
|
||||||
else {}
|
else {}
|
||||||
),
|
),
|
||||||
"generation_source": generation_source,
|
|
||||||
}
|
}
|
||||||
source_result["mode_creation"] = {
|
source_result["mode_creation"] = {
|
||||||
"requested_utterance": utterance,
|
"requested_utterance": utterance,
|
||||||
@@ -4851,17 +5467,9 @@ class SeatDemoService:
|
|||||||
"source_status": source_result.get("status"),
|
"source_status": source_result.get("status"),
|
||||||
"source_provider": source_result.get("provider_used"),
|
"source_provider": source_result.get("provider_used"),
|
||||||
"operation_count": len(reusable_actions),
|
"operation_count": len(reusable_actions),
|
||||||
**(
|
**existing_binding,
|
||||||
{
|
|
||||||
"existing_mode_id": existing_profile.id,
|
|
||||||
"existing_mode_revision": (
|
|
||||||
self.mode_profiles.profile_revision(existing_profile)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
if existing_profile is not None
|
|
||||||
else {}
|
|
||||||
),
|
|
||||||
"generation_source": generation_source,
|
"generation_source": generation_source,
|
||||||
|
"draft_revision": source_revision + 1,
|
||||||
}
|
}
|
||||||
source_result["orchestration_route"] = "mode_profile_create_preview"
|
source_result["orchestration_route"] = "mode_profile_create_preview"
|
||||||
source_result["decision_summary"] = [
|
source_result["decision_summary"] = [
|
||||||
@@ -4871,6 +5479,8 @@ class SeatDemoService:
|
|||||||
if source_profile is not None
|
if source_profile is not None
|
||||||
else f"Codex 已根据场景语义组合并验证“{draft_mode_name}”的工程能力。"
|
else f"Codex 已根据场景语义组合并验证“{draft_mode_name}”的工程能力。"
|
||||||
if generation_source == "codex_scene_composition"
|
if generation_source == "codex_scene_composition"
|
||||||
|
else f"已调整并重新校验待保存的“{draft_mode_name}”模式草稿。"
|
||||||
|
if generation_source == "draft_revision"
|
||||||
else f"已构建并验证“{mode_name}”场景。"
|
else f"已构建并验证“{mode_name}”场景。"
|
||||||
),
|
),
|
||||||
*list(source_result.get("decision_summary", [])),
|
*list(source_result.get("decision_summary", [])),
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ from cognitive_agent.application.codex_assistant import ( # noqa: E402
|
|||||||
CodexVehicleToolError,
|
CodexVehicleToolError,
|
||||||
)
|
)
|
||||||
from cognitive_agent.application.gesture_bridge import GestureCapabilityBridge # noqa: E402
|
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.seat_service import SeatDemoService # noqa: E402
|
||||||
from cognitive_agent.application.self_extension import ( # noqa: E402
|
from cognitive_agent.application.self_extension import ( # noqa: E402
|
||||||
ExtensionIntegrationError,
|
ExtensionIntegrationError,
|
||||||
@@ -1657,6 +1661,25 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
if payload.get("reasoning_effort") is not None
|
if payload.get("reasoning_effort") is not None
|
||||||
else "low"
|
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] = {
|
common_arguments: dict[str, object] = {
|
||||||
"utterance": message,
|
"utterance": message,
|
||||||
# Codex has already converted an implicit request into one explicit
|
# 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(),
|
"hardware_access_bypass": self._hardware_access_bypass_allowed(),
|
||||||
"reasoning_effort": reasoning_effort,
|
"reasoning_effort": reasoning_effort,
|
||||||
"control_transport": str(payload.get("control_transport") or "wired_can"),
|
"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_catalog = self._assistant_capability_catalog()
|
||||||
capability_snapshot = capability_catalog.snapshot()
|
capability_snapshot = capability_catalog.snapshot()
|
||||||
@@ -1783,6 +1815,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
control_terms=capability_catalog.control_terms(capability_snapshot),
|
control_terms=capability_catalog.control_terms(capability_snapshot),
|
||||||
query_capabilities=capability_catalog.query,
|
query_capabilities=capability_catalog.query,
|
||||||
authoritative_answer=fast_answer.answer if fast_answer else None,
|
authoritative_answer=fast_answer.answer if fast_answer else None,
|
||||||
|
mode_profile_draft_context=mode_profile_draft_context,
|
||||||
)
|
)
|
||||||
except HardwareAccessRequiredError as exc:
|
except HardwareAccessRequiredError as exc:
|
||||||
self.vehicle_display_sync.update_task(
|
self.vehicle_display_sync.update_task(
|
||||||
|
|||||||
@@ -186,7 +186,10 @@ class FakeVehicleService:
|
|||||||
if self.fail:
|
if self.fail:
|
||||||
raise RuntimeError("private adapter failure")
|
raise RuntimeError("private adapter failure")
|
||||||
utterance = str(kwargs["utterance"])
|
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"]
|
callback = kwargs["on_commands_validated"]
|
||||||
assert callable(callback)
|
assert callable(callback)
|
||||||
callback(
|
callback(
|
||||||
@@ -199,7 +202,13 @@ class FakeVehicleService:
|
|||||||
)
|
)
|
||||||
if mode_preview:
|
if mode_preview:
|
||||||
movie_mode = "观影模式" in utterance
|
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 = (
|
instruction = (
|
||||||
"遮阳帘关闭,车窗关闭,副驾座椅调整为零重力模式,灯光全部关闭"
|
"遮阳帘关闭,车窗关闭,副驾座椅调整为零重力模式,灯光全部关闭"
|
||||||
if movie_mode
|
if movie_mode
|
||||||
@@ -297,6 +306,17 @@ class FakeVehicleService:
|
|||||||
"vehicle_id": "denza_n7",
|
"vehicle_id": "denza_n7",
|
||||||
"domains": domains,
|
"domains": domains,
|
||||||
"operation_count": len(actions),
|
"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}},
|
"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:
|
def test_exact_movie_mode_request_streams_a_nine_action_preview(self) -> None:
|
||||||
events = self.post_assistant(
|
events = self.post_assistant(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -656,7 +656,7 @@ operations:
|
|||||||
) as response:
|
) as response:
|
||||||
extension_styles = response.read().decode("utf-8").replace("\r\n", "\n")
|
extension_styles = response.read().decode("utf-8").replace("\r\n", "\n")
|
||||||
with self.opener.open(
|
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:
|
) as response:
|
||||||
assistant_script = response.read().decode("utf-8")
|
assistant_script = response.read().decode("utf-8")
|
||||||
with self.opener.open(
|
with self.opener.open(
|
||||||
@@ -718,8 +718,8 @@ operations:
|
|||||||
self.assertNotIn("滚动查看更多", html)
|
self.assertNotIn("滚动查看更多", html)
|
||||||
self.assertIn('aria-label="发送消息"', html)
|
self.assertIn('aria-label="发送消息"', html)
|
||||||
self.assertIn('id="assistantTranscript"', html)
|
self.assertIn('id="assistantTranscript"', html)
|
||||||
self.assertIn('href="/assistant-chat.css?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-22"', 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('href="/energy-monitor.css?v=energy-monitor-4"', html)
|
||||||
self.assertIn('src="/energy-monitor.js?v=energy-monitor-4"', html)
|
self.assertIn('src="/energy-monitor.js?v=energy-monitor-4"', html)
|
||||||
self.assertNotIn('"admin"', console_script)
|
self.assertNotIn('"admin"', console_script)
|
||||||
@@ -992,11 +992,11 @@ operations:
|
|||||||
self.assertIn("CAN 指令已提交,等待车辆状态确认", console_script)
|
self.assertIn("CAN 指令已提交,等待车辆状态确认", console_script)
|
||||||
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
|
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
|
||||||
self.assertIn('"cognitive-agent:module-change"', 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.css?v=delivery-state-65', service_worker)
|
||||||
self.assertIn('/console-v2.js?v=delivery-state-62', 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.css?v=codex-assistant-23', service_worker)
|
||||||
self.assertIn('/assistant-chat.js?v=codex-assistant-22', 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.css?v=energy-monitor-4', service_worker)
|
||||||
self.assertIn('/energy-monitor.js?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)
|
self.assertIn('"/app.js?v=dual-seat-127"', service_worker)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from shutil import copytree
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
@@ -96,6 +97,9 @@ class MultiDomainServiceTests(unittest.TestCase):
|
|||||||
self.temporary_directory = tempfile.TemporaryDirectory()
|
self.temporary_directory = tempfile.TemporaryDirectory()
|
||||||
self.addCleanup(self.temporary_directory.cleanup)
|
self.addCleanup(self.temporary_directory.cleanup)
|
||||||
self.service.artifact_root = Path(self.temporary_directory.name)
|
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(
|
def test_driver_massage_mode_and_strength_continuations_compile_locally(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -9,8 +9,13 @@ from unittest.mock import MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from cognitive_agent.application.codex_assistant import CodexAssistantService
|
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.assistant_capabilities import AssistantCapabilityCatalog
|
||||||
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
||||||
|
from cognitive_agent.application.native_can_runtime import NativeCanCompileError
|
||||||
from cognitive_agent.application.seat_service import (
|
from cognitive_agent.application.seat_service import (
|
||||||
REPOSITORY_ROOT,
|
REPOSITORY_ROOT,
|
||||||
SeatDemoService,
|
SeatDemoService,
|
||||||
@@ -130,6 +135,68 @@ def _intent_actions(result: dict[str, object]) -> list[dict[str, object]]:
|
|||||||
return actions
|
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(
|
def test_name_only_nap_request_reuses_the_verified_repository_profile(
|
||||||
vehicle_service: SeatDemoService,
|
vehicle_service: SeatDemoService,
|
||||||
tmp_path: Path,
|
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)
|
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(
|
def test_codex_composes_an_unregistered_name_only_scene_from_runtime_capabilities(
|
||||||
vehicle_service: SeatDemoService,
|
vehicle_service: SeatDemoService,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ class MockElement extends MockEventTarget {
|
|||||||
this.value = "";
|
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) {
|
append(...nodes) {
|
||||||
nodes.forEach((node) => {
|
nodes.forEach((node) => {
|
||||||
this.children.push(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() {}
|
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) {
|
replaceChildren(...nodes) {
|
||||||
this.children = [];
|
this.children = [];
|
||||||
this.append(...nodes);
|
this.append(...nodes);
|
||||||
@@ -133,7 +161,9 @@ function createHarness(events) {
|
|||||||
document.createTextNode = (text) => ({ nodeType: 3, textContent: String(text) });
|
document.createTextNode = (text) => ({ nodeType: 3, textContent: String(text) });
|
||||||
document.querySelector = (selector) => selectors.get(selector) || null;
|
document.querySelector = (selector) => selectors.get(selector) || null;
|
||||||
document.querySelectorAll = (selector) => (
|
document.querySelectorAll = (selector) => (
|
||||||
selector === "#assistantMessages [data-message-role]" ? [...messages.children] : []
|
selector === "#assistantMessages [data-message-role]"
|
||||||
|
? [...messages.children]
|
||||||
|
: messages.querySelectorAll(selector)
|
||||||
);
|
);
|
||||||
|
|
||||||
const storage = new Map();
|
const storage = new Map();
|
||||||
@@ -181,6 +211,15 @@ function createHarness(events) {
|
|||||||
sandbox.CognitiveAgentNetwork = {
|
sandbox.CognitiveAgentNetwork = {
|
||||||
async request(url, options) {
|
async request(url, options) {
|
||||||
requestCalls.push({ 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);
|
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.length, 1);
|
||||||
assert.equal(harness.completed[0].error, null);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,15 +13,15 @@ def test_console_contains_ai_assistant_surface():
|
|||||||
assert 'aria-label="AI助手对话"' in html
|
assert 'aria-label="AI助手对话"' in html
|
||||||
assert 'placeholder="向 AI助手提问或交代工作"' in html
|
assert 'placeholder="向 AI助手提问或交代工作"' in html
|
||||||
assert 'aria-label="发送消息"' in html
|
assert 'aria-label="发送消息"' in html
|
||||||
assert 'href="/assistant-chat.css?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-22"' 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():
|
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")
|
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 'const CACHE_VERSION = "cognitive-agent-pad-v272"' in service_worker
|
||||||
assert '"/assistant-chat.css?v=codex-assistant-22"' in service_worker
|
assert '"/assistant-chat.css?v=codex-assistant-23"' in service_worker
|
||||||
assert '"/assistant-chat.js?v=codex-assistant-22"' 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():
|
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
|
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():
|
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")
|
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")
|
css = (ROOT / "apps/web-console/assistant-chat.css").read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -341,6 +341,7 @@ def _stream(
|
|||||||
execute_control: Callable[..., dict[str, object]] | None = None,
|
execute_control: Callable[..., dict[str, object]] | None = None,
|
||||||
capability_snapshot: dict[str, object] | None = None,
|
capability_snapshot: dict[str, object] | None = None,
|
||||||
capability_context: 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]]]:
|
) -> tuple[dict[str, object], list[dict[str, object]]]:
|
||||||
events: list[dict[str, object]] = []
|
events: list[dict[str, object]] = []
|
||||||
result = assistant.stream(
|
result = assistant.stream(
|
||||||
@@ -354,6 +355,7 @@ def _stream(
|
|||||||
control_terms=CONTROL_TERMS,
|
control_terms=CONTROL_TERMS,
|
||||||
capability_snapshot=capability_snapshot,
|
capability_snapshot=capability_snapshot,
|
||||||
capability_context=capability_context,
|
capability_context=capability_context,
|
||||||
|
mode_profile_draft_context=mode_profile_draft_context,
|
||||||
)
|
)
|
||||||
return result, events
|
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"
|
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:
|
def test_unknown_name_only_mode_uses_codex_and_overrides_model_execution_claim() -> None:
|
||||||
client = FakeCodexClient()
|
client = FakeCodexClient()
|
||||||
assistant = CodexAssistantService(client)
|
assistant = CodexAssistantService(client)
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ def test_energy_monitor_assets_are_versioned_and_cached() -> None:
|
|||||||
):
|
):
|
||||||
assert asset in html
|
assert asset in html
|
||||||
assert f'"{asset}"' in service_worker
|
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"
|
"/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:
|
def test_energy_monitor_uses_the_fifth_shared_dock_icon_and_starts_closed() -> None:
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None:
|
|||||||
):
|
):
|
||||||
assert asset in html
|
assert asset in html
|
||||||
assert f'"{asset}"' in service_worker
|
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:
|
def test_vehicle_twin_icons_and_nested_overlays_use_semantic_tokens() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user