feat: add seat-scoped static and dynamic assistant modes

This commit is contained in:
vee
2026-09-12 14:56:30 +08:00
parent d09b5967f8
commit d8dad22ec0
60 changed files with 5847 additions and 149 deletions
+217 -13
View File
@@ -4860,7 +4860,18 @@ function modeOperationParameters(operation) {
return Object.entries(parameters); return Object.entries(parameters);
} }
function formatModeOperationValue(name, value) { function formatModeOperationValue(name, value, capability = "") {
if (name === "level" && ["seat.set_driver_ventilation_native", "seat.set_driver_heating_native"].includes(capability)) {
return { 1: "高档", 2: "低档" }[value] || formatConfigValue(name, value);
}
if (capability === "seat.set_driver_massage_native") {
const labels = {
action: { mode: "切换按摩模式", strength: "调节按摩力度", off: "关闭" },
mode: { wave: "波浪", pulse: "脉冲", stretch: "舒展", slack: "舒缓", relax: "放松" },
strength: { 1: "轻柔", 2: "中等", 3: "较强" },
};
if (labels[name]?.[value]) return labels[name][value];
}
const valueLabels = { const valueLabels = {
on: "开启", on: "开启",
off: "关闭", off: "关闭",
@@ -4887,7 +4898,7 @@ function modeOperationView(operation) {
const parameters = modeOperationParameters(operation) const parameters = modeOperationParameters(operation)
.map(([name, value]) => { .map(([name, value]) => {
const label = CONFIG_PARAMETER_LABELS[name] || name; const label = CONFIG_PARAMETER_LABELS[name] || name;
return `${label}:${formatModeOperationValue(name, value)}`; return `${label}:${formatModeOperationValue(name, value, capability)}`;
}) })
.join(","); .join(",");
return { return {
@@ -4926,7 +4937,50 @@ function modeTimelineStepView(step, action) {
}; };
} }
function modeScheduleDurationLabel(durationMs) {
const minutes = Math.floor(durationMs / 60000);
const seconds = Number(((durationMs % 60000) / 1000).toFixed(3));
return minutes ? `${minutes}分${seconds ? `${seconds}秒` : ""}` : `${seconds}秒`;
}
function modeScheduleView(mode) {
const schedule = mode?.schedule;
if (
schedule?.schema !== "cognitive-agent-mode-schedule/v1"
|| !Array.isArray(schedule.phases) || !schedule.phases.length
|| !Number.isInteger(schedule.repeat_count)
|| schedule.repeat_count < 1 || schedule.repeat_count > 20
|| schedule.phases.some((phase) => !Number.isFinite(phase?.duration_ms)
|| phase.duration_ms <= 0 || !Array.isArray(phase.operations))
) return null;
let offsetMs = 0;
const phases = schedule.phases.map((phase, index) => {
const view = {
name: String(phase.name || `阶段 ${index + 1}`),
timing: `+${modeScheduleDurationLabel(offsetMs)} 开始 · 持续 ${modeScheduleDurationLabel(phase.duration_ms)}`,
actions: phase.operations.map(modeOperationView),
};
offsetMs += phase.duration_ms;
return view;
});
return {
phases,
summary: `${phases.length} 个阶段 · 重复 ${schedule.repeat_count} 次 · 总时长 ${modeScheduleDurationLabel(offsetMs * schedule.repeat_count)}`,
stopHint: "执行后可输入“停止动态模式”提前结束,已执行状态不自动回滚;时间为每轮的相对时间。",
};
}
function modeExecutionActionViews(mode) { function modeExecutionActionViews(mode) {
const schedule = modeScheduleView(mode);
if (schedule) {
// Runtime operations may contain a flattened projection. Prefer phases so
// repeated device targets remain distinct and are not listed twice.
return schedule.phases.flatMap((phase, index) => phase.actions.map((action) => ({
...action,
phase: `阶段 ${index + 1}:${phase.name}`,
timing: phase.timing,
})));
}
const operations = Array.isArray(mode?.operations) const operations = Array.isArray(mode?.operations)
? mode.operations.map(modeOperationView) ? mode.operations.map(modeOperationView)
: []; : [];
@@ -4947,6 +5001,57 @@ function modeExecutionActionViews(mode) {
return [...operations, ...timelineSteps]; return [...operations, ...timelineSteps];
} }
function modeScheduleTrialMatches(draft, payload) {
if (!draft?.schedule) return true;
if (!modeScheduleView(draft) || !modeScheduleView(payload)) return false;
const signature = (schedule) => JSON.stringify([
schedule.schema, schedule.repeat_count,
schedule.phases.map((phase) => [
phase.id, phase.name, phase.duration_ms,
phase.operations.map((operation) => [
operation.domain, operation.capability,
operation.target_scope || operation.target?.instance || operation.target?.zone,
modeOperationParameters(operation).sort(([left], [right]) => left.localeCompare(right)),
]),
]),
]);
const execution = payload.schedule_execution;
const totalDurationMs = draft.schedule.phases.reduce((total, phase) => total + phase.duration_ms, 0)
* draft.schedule.repeat_count;
return signature(draft.schedule) === signature(payload.schedule)
&& execution?.status === "completed" && execution.completed === true
&& execution.virtual_elapsed_ms === totalDurationMs;
}
function modeScheduleExecutionView(result) {
const execution = result?.schedule_execution;
if (!execution || typeof execution !== "object") return null;
const status = String(execution.status || "");
if (status === "running" && result.status === "running") {
return {
state: "processing", badge: "已启动", title: "动态时间轴已启动",
detail: "后续动作将按阶段执行;可输入“停止动态模式”提前结束。已执行的设备状态不会自动回滚。",
};
}
if (["stopped", "cancelled"].includes(status)) {
return {
state: "succeeded", badge: "已停止", title: "动态时间轴已停止",
detail: "后续阶段不再执行;已经执行的设备状态不会自动回滚。",
};
}
if (status === "completed" && execution.completed === true) {
const simulated = execution.virtual_elapsed_ms != null;
return {
state: "succeeded", badge: simulated ? "模拟通过" : "已结束",
title: simulated ? "动态时间轴模拟试运行通过" : "动态时间轴已结束",
detail: simulated
? "已使用虚拟时钟校验全部阶段;未按真实时间等待,也未向车辆下发控制。"
: "已处理全部计划阶段,请以车辆反馈确认各设备的实际状态。",
};
}
return null;
}
function isUserModeProfile(mode) { function isUserModeProfile(mode) {
return mode?.origin === "user" || mode?.deletable === true; return mode?.origin === "user" || mode?.deletable === true;
} }
@@ -4954,6 +5059,7 @@ function isUserModeProfile(mode) {
function isDynamicModeProfile(mode) { function isDynamicModeProfile(mode) {
const behavior = String(mode?.behavior || "").trim().toLowerCase(); const behavior = String(mode?.behavior || "").trim().toLowerCase();
if (["cyclic", "dynamic"].includes(behavior)) return true; if (["cyclic", "dynamic"].includes(behavior)) return true;
if (modeScheduleView(mode)) return true;
if (behavior === "static") return false; if (behavior === "static") return false;
if (mode?.timeline && typeof mode.timeline === "object") return true; if (mode?.timeline && typeof mode.timeline === "object") return true;
return LEGACY_DYNAMIC_MODE_IDS.has(String(mode?.id || "")); return LEGACY_DYNAMIC_MODE_IDS.has(String(mode?.id || ""));
@@ -5034,6 +5140,34 @@ function createModeGalleryCard(mode) {
button.append(iconBox, copy); button.append(iconBox, copy);
shell.append(button); shell.append(button);
const schedule = modeScheduleView(mode);
if (schedule) {
meta.textContent = schedule.summary;
const details = document.createElement("details");
details.className = "mode-gallery-schedule-details";
const summary = document.createElement("summary");
summary.textContent = "查看分阶段执行动作";
const list = document.createElement("ol");
schedule.phases.forEach((phase) => {
const item = document.createElement("li");
const title = document.createElement("strong");
title.textContent = phase.name;
const timing = document.createElement("small");
timing.textContent = phase.timing;
item.append(title, timing);
phase.actions.forEach((action) => {
const row = document.createElement("p");
row.textContent = `${action.target} · ${action.action}:${action.parameters}`;
item.append(row);
});
list.append(item);
});
const hint = document.createElement("p");
hint.textContent = schedule.stopHint;
details.append(summary, list, hint);
shell.append(details);
}
const editButton = document.createElement("button"); const editButton = document.createElement("button");
editButton.className = "mode-gallery-edit"; editButton.className = "mode-gallery-edit";
editButton.type = "button"; editButton.type = "button";
@@ -5998,12 +6132,19 @@ function ensureModeActionPopover() {
function renderModeActionPopover(mode) { function renderModeActionPopover(mode) {
const popover = ensureModeActionPopover(); const popover = ensureModeActionPopover();
const actions = modeExecutionActionViews(mode); const actions = modeExecutionActionViews(mode);
const schedule = modeScheduleView(mode);
$("[data-mode-action-title]", popover).textContent = mode?.name || mode?.id || "功能模式"; $("[data-mode-action-title]", popover).textContent = mode?.name || mode?.id || "功能模式";
$("[data-mode-action-count]", popover).textContent = actions.length $("[data-mode-action-count]", popover).textContent = actions.length
? `${actions.length} 步` ? `${actions.length} 步`
: `${Number(mode?.operation_count || 0)} 项`; : `${Number(mode?.operation_count || 0)} 项`;
const list = $("[data-mode-action-list]", popover); const list = $("[data-mode-action-list]", popover);
list.replaceChildren(); list.replaceChildren();
if (schedule) {
const summary = document.createElement("li");
summary.className = "mode-schedule-popover-summary";
summary.textContent = `${schedule.summary}。${schedule.stopHint}`;
list.append(summary);
}
if (!actions.length) { if (!actions.length) {
const empty = document.createElement("li"); const empty = document.createElement("li");
empty.className = "mode-chip-action-empty"; empty.className = "mode-chip-action-empty";
@@ -6020,6 +6161,12 @@ function renderModeActionPopover(mode) {
copy.className = "mode-chip-action-copy"; copy.className = "mode-chip-action-copy";
const actionName = document.createElement("strong"); const actionName = document.createElement("strong");
actionName.textContent = action.action; actionName.textContent = action.action;
if (action.phase) {
const phase = document.createElement("small");
phase.className = "mode-schedule-popover-phase";
phase.textContent = `${action.phase} · ${action.timing}`;
copy.append(phase);
}
const metadata = document.createElement("small"); const metadata = document.createElement("small");
const device = document.createElement("span"); const device = document.createElement("span");
device.innerHTML = "<b>设备</b>"; device.innerHTML = "<b>设备</b>";
@@ -6189,8 +6336,17 @@ function populateModeChips() {
button.append(runIcon); button.append(runIcon);
} }
const timeline = mode.timeline; const timeline = mode.timeline;
const schedule = modeScheduleView(mode);
let cycleBadge = $(".mode-cycle-badge", button); let cycleBadge = $(".mode-cycle-badge", button);
if (["infinite", "until_stopped_or_timeout"].includes(timeline?.repeat)) { if (schedule) {
if (!cycleBadge) {
cycleBadge = document.createElement("small");
cycleBadge.className = "mode-cycle-badge";
button.append(cycleBadge);
}
cycleBadge.textContent = schedule.summary;
button.dataset.modeBehavior = "dynamic";
} else if (["infinite", "until_stopped_or_timeout"].includes(timeline?.repeat)) {
if (!cycleBadge) { if (!cycleBadge) {
cycleBadge = document.createElement("small"); cycleBadge = document.createElement("small");
cycleBadge.className = "mode-cycle-badge"; cycleBadge.className = "mode-cycle-badge";
@@ -6222,7 +6378,7 @@ function populateModeChips() {
: `${phase.name} 目标到位`; : `${phase.name} 目标到位`;
}).join(" → ") }).join(" → ")
: ""; : "";
button.title = [mode.description || mode.name, phaseSummary, "悬浮或聚焦查看全部执行动作"] button.title = [mode.description || mode.name, schedule?.summary || phaseSummary, "悬浮或聚焦查看全部执行动作"]
.filter(Boolean) .filter(Boolean)
.join("\n"); .join("\n");
}); });
@@ -6336,7 +6492,7 @@ function setModeExecutionState(modeProfileId, modeState = "idle") {
$$(".mode-chips button[data-mode-profile-id], #modeGallery button[data-mode-profile-id]").forEach((button) => { $$(".mode-chips button[data-mode-profile-id], #modeGallery button[data-mode-profile-id]").forEach((button) => {
const active = button.dataset.modeProfileId === modeProfileId && modeState !== "idle"; const active = button.dataset.modeProfileId === modeProfileId && modeState !== "idle";
button.classList.toggle("running", active && modeState === "running"); button.classList.toggle("running", active && modeState === "running");
button.classList.toggle("succeeded", active && modeState === "succeeded"); button.classList.toggle("succeeded", active && ["succeeded", "started", "ended", "stopped"].includes(modeState));
button.classList.toggle("failed", active && modeState === "failed"); button.classList.toggle("failed", active && modeState === "failed");
button.dataset.modeState = active ? modeState : "idle"; button.dataset.modeState = active ? modeState : "idle";
button.setAttribute("aria-pressed", active ? "true" : "false"); button.setAttribute("aria-pressed", active ? "true" : "false");
@@ -6345,6 +6501,12 @@ function setModeExecutionState(modeProfileId, modeState = "idle") {
if (stateLabel) { if (stateLabel) {
stateLabel.textContent = active && modeState === "running" stateLabel.textContent = active && modeState === "running"
? "执行中" ? "执行中"
: active && modeState === "started"
? "已启动"
: active && modeState === "ended"
? "已结束"
: active && modeState === "stopped"
? "已停止"
: active && modeState === "succeeded" : active && modeState === "succeeded"
? "已完成" ? "已完成"
: active && modeState === "failed" : active && modeState === "failed"
@@ -6352,7 +6514,7 @@ function setModeExecutionState(modeProfileId, modeState = "idle") {
: "点击执行"; : "点击执行";
} }
}); });
if (modeProfileId && ["succeeded", "failed"].includes(modeState)) { if (modeProfileId && ["succeeded", "failed", "started", "ended", "stopped"].includes(modeState)) {
state.modeRunTimer = window.setTimeout(() => setModeExecutionState(null), 2800); state.modeRunTimer = window.setTimeout(() => setModeExecutionState(null), 2800);
} }
} }
@@ -6407,14 +6569,19 @@ async function executeSavedModeProfile(mode) {
); );
if (!payload) throw new Error("模式卡片未返回执行结果"); if (!payload) throw new Error("模式卡片未返回执行结果");
renderResult(payload); renderResult(payload);
const scheduleRunning = modeScheduleExecutionView(payload)?.state === "processing";
const completed = ["succeeded", "partially_succeeded"].includes(payload.status); const completed = ["succeeded", "partially_succeeded"].includes(payload.status);
$("#utterance").value = mode.name || mode.id; $("#utterance").value = mode.name || mode.id;
$("#resultTitle").textContent = completed $("#resultTitle").textContent = scheduleRunning
? `${mode.name}时间轴已启动`
: completed
? `${mode.name}执行完成` ? `${mode.name}执行完成`
: `${mode.name}执行未完成`; : `${mode.name}执行未完成`;
setModeExecutionState(mode.id, completed ? "succeeded" : "failed"); setModeExecutionState(mode.id, scheduleRunning ? "started" : completed ? "succeeded" : "failed");
if (state.executionMode === "hardware") await synchronizePhysicalSeatState(); if (state.executionMode === "hardware") await synchronizePhysicalSeatState();
showToast(completed showToast(scheduleRunning
? "时间轴已启动,可输入“停止动态模式”提前结束"
: completed
? `${mode.name}已直接执行保存的控制指令流` ? `${mode.name}已直接执行保存的控制指令流`
: `${mode.name}未完成,请查看执行结果`); : `${mode.name}未完成,请查看执行结果`);
} catch (error) { } catch (error) {
@@ -7489,6 +7656,9 @@ const CONFIG_DOMAIN_META = {
const CONFIG_CAPABILITY_LABELS = { const CONFIG_CAPABILITY_LABELS = {
"seat.set_ventilation": "设置座椅通风", "seat.set_ventilation": "设置座椅通风",
"seat.set_heating": "设置座椅加热", "seat.set_heating": "设置座椅加热",
"seat.set_driver_ventilation_native": "设置主驾座椅通风",
"seat.set_driver_heating_native": "设置主驾座椅加热",
"seat.set_driver_massage_native": "设置主驾座椅按摩",
"seat.set_longitudinal": "调节座椅前后位置", "seat.set_longitudinal": "调节座椅前后位置",
"seat.set_longitudinal_position": "调节座椅前后位置", "seat.set_longitudinal_position": "调节座椅前后位置",
"seat.set_cushion_tilt": "调节座椅高度", "seat.set_cushion_tilt": "调节座椅高度",
@@ -7519,6 +7689,8 @@ const CONFIG_CAPABILITY_LABELS = {
"ambient_light.set_illumination": "设置氛围灯颜色与亮度", "ambient_light.set_illumination": "设置氛围灯颜色与亮度",
"ambient_light.set_color": "设置氛围灯颜色", "ambient_light.set_color": "设置氛围灯颜色",
"ambient_light.set_brightness": "设置氛围灯亮度", "ambient_light.set_brightness": "设置氛围灯亮度",
"ambient_light.set_brightness_level": "设置氛围灯亮度",
"ambient_light.set_color_index": "设置氛围灯色号",
"ambient_light.set_mode": "设置氛围灯模式", "ambient_light.set_mode": "设置氛围灯模式",
"roof.set_transmittance": "设置天幕透光率", "roof.set_transmittance": "设置天幕透光率",
"roof.set_shade_position": "设置遮阳帘位置", "roof.set_shade_position": "设置遮阳帘位置",
@@ -7527,6 +7699,10 @@ const CONFIG_CAPABILITY_LABELS = {
}; };
const CONFIG_PARAMETER_LABELS = { const CONFIG_PARAMETER_LABELS = {
action: "动作",
strength: "力度",
color_index: "色号",
brightness_level: "亮度档位",
power: "电源", power: "电源",
state: "状态", state: "状态",
level: "档位", level: "档位",
@@ -7813,6 +7989,24 @@ function renderExecution(result) {
const summary = $("#executionSummary"); const summary = $("#executionSummary");
const list = $("#resultList"); const list = $("#resultList");
list.replaceChildren(); list.replaceChildren();
const scheduleExecution = modeScheduleExecutionView(result);
if (scheduleExecution) {
summary.className = `execution-summary ${scheduleExecution.state === "processing" ? "partial" : scheduleExecution.state}`;
const icon = document.createElement("i");
icon.dataset.lucide = scheduleExecution.state === "processing" ? "timer" : "circle-check";
const copy = document.createElement("div");
const title = document.createElement("strong");
title.textContent = scheduleExecution.title;
const detail = document.createElement("span");
detail.textContent = scheduleExecution.detail;
copy.append(title, detail);
summary.replaceChildren(icon, copy);
$("#resultNavMeta").textContent = scheduleExecution.badge;
$("#resultPanelState").textContent = scheduleExecution.badge;
$("#resultPanelState").className = `workspace-panel-state ${scheduleExecution.state}`;
refreshIcons();
return;
}
const report = result.execution_report; const report = result.execution_report;
if (!report) { if (!report) {
summary.className = "execution-summary failed"; summary.className = "execution-summary failed";
@@ -8084,6 +8278,7 @@ window.addEventListener("cognitive-agent:extension-vehicle-result", (event) => {
function renderResult(result) { function renderResult(result) {
state.result = result; state.result = result;
const scheduleExecution = modeScheduleExecutionView(result);
const reportMode = result.execution_report?.spec?.mode; const reportMode = result.execution_report?.spec?.mode;
const feedbackSource = result.execution_report?.metadata?.labels?.seat_feedback; const feedbackSource = result.execution_report?.metadata?.labels?.seat_feedback;
const submittedUnverified = executionVerificationStatus(result) === "submitted_unverified"; const submittedUnverified = executionVerificationStatus(result) === "submitted_unverified";
@@ -8118,7 +8313,9 @@ function renderResult(result) {
}; };
const peripheralRoute = String(result.orchestration_route || "") const peripheralRoute = String(result.orchestration_route || "")
.startsWith("nfc_peripheral"); .startsWith("nfc_peripheral");
$("#resultTitle").textContent = peripheralRoute && executionSucceeded $("#resultTitle").textContent = scheduleExecution
? `${result.mode_profile?.name ? `${result.mode_profile.name} · ` : ""}${scheduleExecution.title}`
: peripheralRoute && executionSucceeded
? `${result.peripheral?.name || "第三方外设"} · ${result.peripheral?.action || "动作已执行"}` ? `${result.peripheral?.name || "第三方外设"} · ${result.peripheral?.action || "动作已执行"}`
: partiallySucceeded : partiallySucceeded
? `${domains.length} 域场景部分完成` ? `${domains.length} 域场景部分完成`
@@ -8142,7 +8339,7 @@ function renderResult(result) {
$("#requestMeta").textContent += ` / reasoning:${result.reasoning_effort_used}`; $("#requestMeta").textContent += ` / reasoning:${result.reasoning_effort_used}`;
} }
setRunStatus( setRunStatus(
partiallySucceeded || submittedUnverified ? "partial" : executionSucceeded ? "succeeded" : "failed", scheduleExecution?.state || (partiallySucceeded || submittedUnverified ? "partial" : executionSucceeded ? "succeeded" : "failed"),
submittedUnverified ? "SUBMITTED_UNVERIFIED" : result.status.toUpperCase(), submittedUnverified ? "SUBMITTED_UNVERIFIED" : result.status.toUpperCase(),
); );
renderDecision(result); renderDecision(result);
@@ -8722,12 +8919,17 @@ window.CognitiveAgentConsole = {
&& expectedSignatures.every((signature, index) => signature === actualSignatures[index])); && expectedSignatures.every((signature, index) => signature === actualSignatures[index]));
if (payload.status !== "succeeded" if (payload.status !== "succeeded"
|| (expectedOperationCount > 0 && actualOperationCount < expectedOperationCount) || (expectedOperationCount > 0 && actualOperationCount < expectedOperationCount)
|| !sameActions) { || !sameActions || !modeScheduleTrialMatches(draft, payload)) {
throw new Error("试运行未完整覆盖草稿动作,请重新生成模式预览。"); throw new Error("试运行未完整覆盖草稿动作,请重新生成模式预览。");
} }
return payload; return payload;
}, },
revealModeProfile, revealModeProfile,
updateScheduleExecutionStatus(modeProfileId, status) {
if (!modeProfileId || (state.pendingModeProfileId && state.pendingModeProfileId !== modeProfileId)) return;
const terminal = { completed: "ended", failed: "failed", cancelled: "stopped", stopped: "stopped", idle: "idle" }[status];
if (terminal) setModeExecutionState(modeProfileId, terminal);
},
notify(message) { notify(message) {
showToast(String(message)); showToast(String(message));
}, },
@@ -8750,9 +8952,11 @@ window.addEventListener("cognitive-agent:request-complete", (event) => {
if (!modeProfileId) return; if (!modeProfileId) return;
state.pendingModeProfileId = null; state.pendingModeProfileId = null;
const result = event.detail?.result; const result = event.detail?.result;
const scheduleRunning = !event.detail?.error
&& modeScheduleExecutionView(result)?.state === "processing";
const succeeded = !event.detail?.error const succeeded = !event.detail?.error
&& ["succeeded", "partially_succeeded"].includes(String(result?.status || "")); && ["succeeded", "partially_succeeded"].includes(String(result?.status || ""));
setModeExecutionState(modeProfileId, succeeded ? "succeeded" : "failed"); setModeExecutionState(modeProfileId, scheduleRunning ? "started" : succeeded ? "succeeded" : "failed");
}); });
document.addEventListener("DOMContentLoaded", async () => { document.addEventListener("DOMContentLoaded", async () => {
+105
View File
@@ -551,6 +551,111 @@
padding: 9px 10px 8px; padding: 9px 10px 8px;
} }
.assistant-mode-schedule-summary,
.assistant-mode-schedule-hint {
margin: 0;
padding: 9px 12px;
color: var(--assistant-muted, #64748b);
font-size: 10px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.assistant-mode-schedule-summary {
color: var(--status-info-foreground, #3764d9);
background: var(--status-info-soft, #eef3ff);
font-weight: 750;
}
.assistant-mode-schedule-phase + .assistant-mode-schedule-phase,
.assistant-mode-schedule-hint {
border-top: 1px solid var(--assistant-border, #e5eaf1);
}
.assistant-mode-schedule-phase > header {
display: grid;
gap: 3px;
padding: 10px 12px 5px;
color: var(--assistant-heading, #334155);
font-size: 11px;
overflow-wrap: anywhere;
}
.assistant-mode-schedule-phase > header small {
color: var(--assistant-muted, #64748b);
font-size: 9px;
font-weight: 500;
line-height: 1.5;
}
.assistant-mode-draft-scheduled .assistant-mode-draft-row {
grid-template-columns: 18px minmax(0, 1fr) minmax(0, 1fr);
gap: 5px;
}
.assistant-mode-draft-scheduled .assistant-mode-draft-row > strong {
white-space: normal;
overflow-wrap: anywhere;
text-align: right;
}
.mode-gallery-card-shell:has(> .mode-gallery-schedule-details) > .mode-gallery-card {
height: auto;
}
.mode-gallery-schedule-details {
padding: 9px 12px;
color: var(--foreground, #334155);
background: var(--card, #fff);
border: 1px solid var(--border, #e5eaf1);
border-radius: 10px;
font-size: 11px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.mode-gallery-schedule-details > summary {
cursor: pointer;
color: var(--primary, #3764d9);
font-weight: 700;
}
.mode-gallery-schedule-details ol {
margin: 10px 0;
padding-left: 19px;
}
.mode-gallery-schedule-details li + li {
margin-top: 10px;
}
.mode-gallery-schedule-details li > strong,
.mode-gallery-schedule-details li > small {
display: block;
}
.mode-gallery-schedule-details small,
.mode-gallery-schedule-details > p {
color: var(--muted-foreground, #64748b);
}
.mode-gallery-schedule-details p {
margin: 4px 0;
}
.mode-chip-action-popover li.mode-schedule-popover-summary {
display: block;
color: var(--status-info-foreground, #3764d9);
background: var(--status-info-soft, #eef3ff);
font-size: 11px;
line-height: 1.6;
}
.mode-schedule-popover-phase {
color: var(--primary, #3764d9);
line-height: 1.5;
}
.assistant-mode-draft-actions[hidden] { .assistant-mode-draft-actions[hidden] {
display: none; display: none;
} }
+192 -17
View File
@@ -11,6 +11,7 @@
let pendingMessage = null; let pendingMessage = null;
let latestControlResult = null; let latestControlResult = null;
let activeModeDraftContext = null; let activeModeDraftContext = null;
let activeSchedulePoll = null;
let initialized = false; let initialized = false;
const $ = (selector, root = document) => root.querySelector(selector); const $ = (selector, root = document) => root.querySelector(selector);
@@ -542,7 +543,7 @@
const consumed = new Set(); const consumed = new Set();
const rows = []; const rows = [];
const targetZone = (action) => String( const targetZone = (action) => String(
action?.target?.instance || action?.target?.zone || action?.target || "", action?.target?.instance || action?.target?.zone || action?.target_scope || action?.target || "",
); );
const parameters = (action) => ( const parameters = (action) => (
action?.parameters && typeof action.parameters === "object" ? action.parameters : {} action?.parameters && typeof action.parameters === "object" ? action.parameters : {}
@@ -613,6 +614,10 @@
const capability = String(action.capability || ""); const capability = String(action.capability || "");
const domain = String(action.domain || ""); const domain = String(action.domain || "");
const zone = targetZone(action); const zone = targetZone(action);
const zoneLabel = {
driver: "主驾", passenger: "副驾", front_left: "主驾", front_right: "副驾",
front_row: "前排", rear_row: "后排", rear_left: "左后", rear_right: "右后",
}[zone] || "";
const params = parameters(action); const params = parameters(action);
let label = capability || domain || "座舱设备"; let label = capability || domain || "座舱设备";
let value = "已配置"; let value = "已配置";
@@ -683,7 +688,7 @@
icon = "armchair"; icon = "armchair";
order = instructionOrder("座椅", "副驾", "主驾"); order = instructionOrder("座椅", "副驾", "主驾");
} else if (capability === "climate.set_temperature") { } else if (capability === "climate.set_temperature") {
label = "空调温度"; label = `${zoneLabel}空调温度`;
value = `${params.temperature_c ?? "--"}℃`; value = `${params.temperature_c ?? "--"}℃`;
icon = "thermometer"; icon = "thermometer";
order = instructionOrder("空调", "温度"); order = instructionOrder("空调", "温度");
@@ -701,13 +706,14 @@
: capability === "climate.set_cycle_mode" ? "空调循环" : capability === "climate.set_cycle_mode" ? "空调循环"
: capability === "climate.set_control_mode" ? "空调控制模式" : capability === "climate.set_control_mode" ? "空调控制模式"
: capability === "climate.set_outlet_mode" ? "空调出风模式" : "空调"; : capability === "climate.set_outlet_mode" ? "空调出风模式" : "空调";
label = `${zoneLabel}${label}`;
value = params.level != null ? `${params.level}档` value = params.level != null ? `${params.level}档`
: params.mode != null ? (climateModeLabels[capability]?.[String(params.mode)] || String(params.mode)) : params.mode != null ? (climateModeLabels[capability]?.[String(params.mode)] || String(params.mode))
: motionLabel(params.state); : motionLabel(params.state);
icon = "fan"; icon = "fan";
order = instructionOrder("空调", "风量"); order = instructionOrder("空调", "风量");
} else if (capability === "interior_light.set_reading_power") { } else if (capability === "interior_light.set_reading_power") {
label = "阅读灯"; label = `${zoneLabel}阅读灯`;
value = motionLabel(params.state); value = motionLabel(params.state);
icon = "book-open"; icon = "book-open";
order = instructionOrder("阅读灯", "灯光"); order = instructionOrder("阅读灯", "灯光");
@@ -734,6 +740,63 @@
return rows.sort((left, right) => left.order - right.order); return rows.sort((left, right) => left.order - right.order);
} }
function modeDraftScheduleView(draft) {
const schedule = draft?.schedule;
if (
schedule?.schema !== "cognitive-agent-mode-schedule/v1"
|| !Array.isArray(schedule.phases) || !schedule.phases.length
|| !Number.isInteger(schedule.repeat_count)
|| schedule.repeat_count < 1 || schedule.repeat_count > 20
|| schedule.phases.some((phase) => !Number.isFinite(phase?.duration_ms)
|| phase.duration_ms <= 0 || !Array.isArray(phase.operations))
) return null;
const durationLabel = (durationMs) => {
const minutes = Math.floor(durationMs / 60000);
const seconds = Number(((durationMs % 60000) / 1000).toFixed(3));
return minutes ? `${minutes}分${seconds ? `${seconds}秒` : ""}` : `${seconds}秒`;
};
let offsetMs = 0;
const phases = schedule.phases.map((phase, index) => {
// Each phase is rendered separately, without instruction-based grouping:
// the same seat or temperature can have a different target later on.
const rows = modeDraftRows({ instruction: "" }, phase.operations.map((operation) => ({
...operation,
target: operation.target || (operation.domain === "seat"
? { instance: operation.target_scope }
: { zone: operation.target_scope }),
})));
const view = {
id: String(phase.id || `phase-${index + 1}`),
name: String(phase.name || `阶段 ${index + 1}`),
timing: `+${durationLabel(offsetMs)} 开始 · 持续 ${durationLabel(phase.duration_ms)}`,
rows,
};
offsetMs += phase.duration_ms;
return view;
});
return {
phases,
operationCount: schedule.phases.reduce((count, phase) => count + phase.operations.length, 0),
summary: `${phases.length} 个阶段 · 重复 ${schedule.repeat_count} 次 · 总时长 ${durationLabel(offsetMs * schedule.repeat_count)}`,
stopHint: "执行后可输入“停止动态模式”提前结束,已执行状态不自动回滚。各阶段按顺序执行,时间为每轮的相对时间。",
};
}
function appendModeDraftRows(container, rows) {
rows.forEach((item) => {
const row = document.createElement("div");
row.className = "assistant-mode-draft-row";
const icon = document.createElement("i");
icon.dataset.lucide = item.icon;
const label = document.createElement("span");
label.textContent = item.label;
const value = document.createElement("strong");
value.textContent = item.value;
row.append(icon, label, value);
container.append(row);
});
}
async function saveModeProfileDraft(message, card, draft, buttons, status) { async function saveModeProfileDraft(message, card, draft, buttons, status) {
buttons.forEach((button) => { button.disabled = true; }); buttons.forEach((button) => { button.disabled = true; });
card.classList.add("is-busy"); card.classList.add("is-busy");
@@ -849,23 +912,39 @@
headingCopy.append(eyebrow, title); headingCopy.append(eyebrow, title);
const badge = document.createElement("span"); const badge = document.createElement("span");
badge.className = "assistant-mode-draft-badge"; badge.className = "assistant-mode-draft-badge";
badge.textContent = `${Number(draft.operation_count) || 0} 项动作`; const scheduleView = modeDraftScheduleView(draft);
badge.textContent = `${scheduleView?.operationCount ?? (Number(draft.operation_count) || 0)} 项动作`;
heading.append(headingCopy, badge); heading.append(headingCopy, badge);
const list = document.createElement("div"); const list = document.createElement("div");
list.className = "assistant-mode-draft-list"; list.className = "assistant-mode-draft-list";
modeDraftRows(draft, actions).forEach((item) => { if (scheduleView) {
const row = document.createElement("div"); card.classList.add("assistant-mode-draft-scheduled");
row.className = "assistant-mode-draft-row"; const summary = document.createElement("p");
const icon = document.createElement("i"); summary.className = "assistant-mode-schedule-summary";
icon.dataset.lucide = item.icon; summary.textContent = scheduleView.summary;
const label = document.createElement("span"); list.append(summary);
label.textContent = item.label; scheduleView.phases.forEach((phase, index) => {
const value = document.createElement("strong"); const section = document.createElement("section");
value.textContent = item.value; section.className = "assistant-mode-schedule-phase";
row.append(icon, label, value); section.dataset.modeSchedulePhase = phase.id;
list.append(row); const phaseHeading = document.createElement("header");
const name = document.createElement("strong");
name.textContent = `${index + 1}. ${phase.name}`;
const timing = document.createElement("small");
timing.textContent = phase.timing;
phaseHeading.append(name, timing);
section.append(phaseHeading);
appendModeDraftRows(section, phase.rows);
list.append(section);
}); });
const hint = document.createElement("p");
hint.className = "assistant-mode-schedule-hint";
hint.textContent = scheduleView.stopHint;
list.append(hint);
} else {
appendModeDraftRows(list, modeDraftRows(draft, actions));
}
const controls = document.createElement("div"); const controls = document.createElement("div");
controls.className = "assistant-mode-draft-actions"; controls.className = "assistant-mode-draft-actions";
@@ -965,6 +1044,90 @@
} }
} }
function stopScheduleStatusPoll() {
if (!activeSchedulePoll) return;
window.clearTimeout(activeSchedulePoll.timer);
activeSchedulePoll.controller?.abort();
activeSchedulePoll = null;
}
function startScheduleStatusPoll(payload, message) {
stopScheduleStatusPoll();
const vehicleId = String(payload?.vehicle_id || payload?.vehicle?.id
|| payload?.mode_profile?.vehicle_id || $("#vehicleSelect")?.value || "");
if (!vehicleId) return;
const poll = {
vehicleId, conversationId: conversationId(), message,
sessionId: String(payload?.schedule_execution?.session_id || ""),
runId: String(payload?.schedule_execution?.run_id || ""),
modeProfileId: String(payload?.mode_profile?.id || ""),
timer: null, controller: null,
};
activeSchedulePoll = poll;
const isCurrent = () => activeSchedulePoll === poll
&& conversationId() === poll.conversationId
&& (!$("#vehicleSelect")?.value || $("#vehicleSelect").value === poll.vehicleId);
const update = async () => {
if (!isCurrent()) {
if (activeSchedulePoll === poll) stopScheduleStatusPoll();
return;
}
poll.controller = new AbortController();
try {
const response = await window.CognitiveAgentNetwork.request(
`/api/modes/schedule/status?vehicle_id=${encodeURIComponent(poll.vehicleId)}`,
{ method: "GET", signal: poll.controller.signal, timeoutMs: 8000, retries: 0 },
);
if (!isCurrent()) return;
if (response.status === 401) {
setActionStatus(message, "登录状态已失效,无法继续查询时间轴状态;请重新登录查看。", "failed");
stopScheduleStatusPoll();
return;
}
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const status = await response.json();
if (!isCurrent()) return;
if (status.vehicle_id && String(status.vehicle_id) !== poll.vehicleId) {
throw new Error("时间轴状态车型不匹配");
}
if ((poll.runId && status.run_id && String(status.run_id) !== poll.runId)
|| (poll.sessionId && status.session_id && String(status.session_id) !== poll.sessionId)) {
setActionStatus(message, "当前时间轴已被新的运行任务替换,请查看最新模式消息。", "working");
stopScheduleStatusPoll();
return;
}
const terminal = ["completed", "failed", "cancelled", "stopped", "idle"].includes(status.status);
if (status.status === "running") {
const phase = Number.isInteger(status.phase_index) && status.phase_index >= 0
? ` · 阶段 ${status.phase_index + 1}` : "";
const repeat = Number.isInteger(status.repeat_index) && status.repeat_index >= 0
? ` · 第 ${status.repeat_index + 1} 轮` : "";
setActionStatus(message, `动态时间轴执行中${repeat}${phase}。可输入“停止动态模式”;已执行状态不自动回滚。`, "working");
} else if (terminal) {
const stopped = ["cancelled", "stopped"].includes(status.status);
const failed = status.status === "failed";
const copy = failed
? `动态时间轴执行失败:${status.error || "某个阶段未通过执行校验"}。后续阶段不再执行,已执行状态不自动回滚。`
: stopped
? "动态时间轴已停止,后续阶段不再执行;已执行状态不自动回滚。"
: status.status === "completed"
? "动态时间轴已结束,全部计划阶段已处理;请以车辆反馈确认设备实际状态。"
: "当前没有正在运行的动态时间轴。";
setActionStatus(message, copy, failed ? "failed" : "success");
window.CognitiveAgentConsole?.updateScheduleExecutionStatus?.(poll.modeProfileId, status.status);
stopScheduleStatusPoll();
return;
}
} catch (error) {
if (!isCurrent() || poll.controller?.signal.aborted) return;
setActionStatus(message, "暂时无法获取时间轴状态,正在重新查询;需要停止时可输入“停止动态模式”。", "working");
} finally {
if (isCurrent()) poll.timer = window.setTimeout(update, 2000);
}
};
poll.timer = window.setTimeout(update, 2000);
}
function applyAssistantEvent(event, message) { function applyAssistantEvent(event, message) {
if (event.type === "answer.delta") { if (event.type === "answer.delta") {
setMessageContent(message, `${message.content}${event.delta || ""}`); setMessageContent(message, `${message.content}${event.delta || ""}`);
@@ -985,14 +1148,20 @@
if (event.type === "action.completed" || event.type === "work.completed") { if (event.type === "action.completed" || event.type === "work.completed") {
latestControlResult = event.payload || latestControlResult; latestControlResult = event.payload || latestControlResult;
const completed = event.success !== false; const completed = event.success !== false;
const scheduleRunning = completed
&& event.payload?.orchestration_route === "mode_schedule_execute"
&& (event.payload?.status === "running" || event.payload?.schedule_execution?.status === "running");
const defaultText = event.type === "work.completed" const defaultText = event.type === "work.completed"
? (completed ? "AI助手已完成工作" : "AI助手未能完成工作") ? (completed ? "AI助手已完成工作" : "AI助手未能完成工作")
: (completed ? "座舱控制已完成" : "座舱控制未完成"); : (completed ? "座舱控制已完成" : "座舱控制未完成");
setActionStatus( setActionStatus(
message, message,
event.display_text || defaultText, scheduleRunning
completed ? "success" : "failed", ? "已启动动态时间轴;后续动作将分阶段执行。可输入“停止动态模式”提前结束,已执行的设备状态不会自动回滚。"
: event.display_text || defaultText,
scheduleRunning ? "working" : completed ? "success" : "failed",
); );
if (scheduleRunning) startScheduleStatusPoll(event.payload, message);
if (event.payload) { if (event.payload) {
window.CognitiveAgentConsole?.applySyncedResult?.(event.payload); window.CognitiveAgentConsole?.applySyncedResult?.(event.payload);
if ( if (
@@ -1238,11 +1407,17 @@
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
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;
stopScheduleStatusPoll();
sessionStorage.removeItem(STORAGE_KEY); sessionStorage.removeItem(STORAGE_KEY);
clearActiveModeDraftContext(); clearActiveModeDraftContext();
sessionStorage.setItem(CONVERSATION_STORAGE_KEY, createConversationId()); sessionStorage.setItem(CONVERSATION_STORAGE_KEY, createConversationId());
}, true); }, true);
document.addEventListener("change", (event) => {
if (event.target?.id === "vehicleSelect") stopScheduleStatusPoll();
});
window.addEventListener("pagehide", stopScheduleStatusPoll);
document.addEventListener("DOMContentLoaded", initialize); document.addEventListener("DOMContentLoaded", initialize);
window.CognitiveAgentAssistant = { window.CognitiveAgentAssistant = {
submit: submitAssistant, submit: submitAssistant,
+3 -3
View File
@@ -13,20 +13,20 @@
<link rel="stylesheet" href="/console-v2.css?v=delivery-state-66" /> <link rel="stylesheet" href="/console-v2.css?v=delivery-state-66" />
<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-27" /> <link rel="stylesheet" href="/assistant-chat.css?v=codex-assistant-28" />
<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>
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script> <script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script> <script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
<script src="/network.js?v=android-pad-2" defer></script> <script src="/network.js?v=android-pad-2" defer></script>
<script src="/app.js?v=dual-seat-131" defer></script> <script src="/app.js?v=dual-seat-132" defer></script>
<script src="/speech-input.js?v=sensevoice-13" defer></script> <script src="/speech-input.js?v=sensevoice-13" defer></script>
<script type="module" src="/gesture-control.js?v=android-pad-9"></script> <script type="module" src="/gesture-control.js?v=android-pad-9"></script>
<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-27" defer></script> <script src="/assistant-chat.js?v=codex-assistant-28" 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">
+4 -4
View File
@@ -1,4 +1,4 @@
const CACHE_VERSION = "cognitive-agent-pad-v278"; const CACHE_VERSION = "cognitive-agent-pad-v279";
const SHELL_CACHE = `${CACHE_VERSION}-shell`; const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`; const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [ const APP_SHELL = [
@@ -6,7 +6,7 @@ const APP_SHELL = [
"/index.html", "/index.html",
"/styles.css?v=dual-seat-23", "/styles.css?v=dual-seat-23",
"/network.js?v=android-pad-2", "/network.js?v=android-pad-2",
"/app.js?v=dual-seat-131", "/app.js?v=dual-seat-132",
"/gesture-control.js?v=android-pad-9", "/gesture-control.js?v=android-pad-9",
"/pwa.js?v=android-pad-3", "/pwa.js?v=android-pad-3",
"/console-v2.css?v=delivery-state-66", "/console-v2.css?v=delivery-state-66",
@@ -16,8 +16,8 @@ const APP_SHELL = [
"/speech-input.css?v=sensevoice-7", "/speech-input.css?v=sensevoice-7",
"/speech-input.js?v=sensevoice-13", "/speech-input.js?v=sensevoice-13",
"/speech-audio-worklet.js?v=sensevoice-5", "/speech-audio-worklet.js?v=sensevoice-5",
"/assistant-chat.css?v=codex-assistant-27", "/assistant-chat.css?v=codex-assistant-28",
"/assistant-chat.js?v=codex-assistant-27", "/assistant-chat.js?v=codex-assistant-28",
"/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",
@@ -2,12 +2,13 @@ id: leisure_lounger
name: 休闲躺椅模式 name: 休闲躺椅模式
aliases: aliases:
- 休闲躺椅 - 休闲躺椅
description: 腾势 N7 副驾休闲躺椅姿态,并联动当前已接入的空调、天幕和氛围灯能力。 description: 腾势 N7 主驾放松按摩与副驾休闲躺椅姿态,并联动当前已接入的空调、天幕和氛围灯能力;驻车使用,不改变主驾驾驶姿态。
icon: sofa icon: sofa
scope: scope:
vehicle_ids: vehicle_ids:
- denza_n7 - denza_n7
operations: operations:
- {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 使用已接入的主驾放松按摩,让主副驾按各自能力联动休闲;主驾没有绝对位置反馈,不自动移动其座椅。}
- domain: seat - domain: seat
capability: seat.set_longitudinal_position capability: seat.set_longitudinal_position
target_scope: passenger target_scope: passenger
@@ -3,12 +3,13 @@ name: 冥想空间模式
aliases: aliases:
- 冥想空间 - 冥想空间
- 冥想 - 冥想
description: 腾势 N7 冥想空间姿态,并联动当前已接入的恒温、低风、内循环、遮阳帘和冥想氛围灯能力。 description: 腾势 N7 主驾放松按摩与副驾冥想空间姿态,并联动当前已接入的恒温、低风、内循环、遮阳帘和冥想氛围灯能力;驻车使用,不改变主驾驾驶姿态。
icon: leaf icon: leaf
scope: scope:
vehicle_ids: vehicle_ids:
- denza_n7 - denza_n7
operations: operations:
- {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 使用当前工程已接入的主驾放松按摩辅助驻车冥想;不假定副驾具有按摩能力,不移动主驾座椅。}
- domain: seat - domain: seat
capability: seat.set_longitudinal_position capability: seat.set_longitudinal_position
target_scope: passenger target_scope: passenger
+2 -1
View File
@@ -2,12 +2,13 @@ id: nap
name: 小憩模式 name: 小憩模式
aliases: aliases:
- 小憩 - 小憩
description: 腾势 N7 副驾小憩姿态,并联动当前已接入的静音恒温、遮阳帘和氛围灯关闭能力。 description: 腾势 N7 主驾低档座椅通风与副驾小憩姿态,并联动当前已接入的静音恒温、遮阳帘和氛围灯关闭能力;驻车使用,不改变主驾驾驶姿态。
icon: moon icon: moon
scope: scope:
vehicle_ids: vehicle_ids:
- denza_n7 - denza_n7
operations: operations:
- {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 主驾使用低档座椅通风保持驻车静坐舒适,不自动移动主驾或开启按摩。}
- domain: seat - domain: seat
capability: seat.set_longitudinal_position capability: seat.set_longitudinal_position
target_scope: passenger target_scope: passenger
+3 -1
View File
@@ -2,12 +2,14 @@ id: office
name: 办公模式 name: 办公模式
aliases: aliases:
- 办公 - 办公
description: 腾势 N7 副驾办公姿态,并联动当前已接入的恒温、中低风、内循环、遮阳帘和冷白氛围灯能力。 description: 腾势 N7 主副驾低档座椅通风与副驾办公姿态,并联动当前已接入的恒温、中低风、内循环、遮阳帘和冷白氛围灯能力;不改变主驾驾驶姿态。
icon: briefcase icon: briefcase
scope: scope:
vehicle_ids: vehicle_ids:
- denza_n7 - denza_n7
operations: operations:
- {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾原生 CAN 低档通风,改善驻车办公时久坐闷热;不移动主驾座椅。}
- {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 使用副驾独立接口低档通风,与办公支撑姿态联动。}
- domain: seat - domain: seat
capability: seat.set_longitudinal_position capability: seat.set_longitudinal_position
target_scope: passenger target_scope: passenger
@@ -0,0 +1,26 @@
id: boarding-warmup
name: 上车预热模式
aliases: [上车预热的功能模式, 上车预热功能模式, 上车暖舱模式, 分阶段预热模式, 动态预热模式, 空调预热模式]
description: 分阶段上车预热;前2分钟以28℃和4档风量快速暖舱,再用24℃和1档风量舒适保持5分钟,并分别编排主副驾加热。总计7分钟,不移动座椅位置;时间轴结束后保持最后设置,不承诺无人值守预约启动。
icon: thermometer
schedule:
schema: cognitive-agent-mode-schedule/v1
repeat_count: 1
phases:
- id: warmup
name: 快速暖舱
duration_ms: 120000
operations:
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: 'on'}, reason: 开启空调。}
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 28}, reason: 初始预热目标。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 4}, reason: 初始阶段较高风量。}
- {domain: seat, capability: seat.set_driver_heating_native, target_scope: driver, operation: set, parameters: {state: 'on', level: 2}, reason: 主驾低档加热接口2为低档。}
- {domain: seat, capability: seat.set_heating, target_scope: passenger, operation: set, parameters: {state: 'on', level: 2}, reason: 副驾使用独立接口2档加热。}
- id: comfort
name: 舒适恒温
duration_ms: 300000
operations:
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 预热结束后降低目标温度。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 降低风噪。}
- {domain: seat, capability: seat.set_driver_heating_native, target_scope: driver, operation: set, parameters: {state: 'on', level: 2}, reason: 主驾维持低档。}
- {domain: seat, capability: seat.set_heating, target_scope: passenger, operation: set, parameters: {state: 'on', level: 1}, reason: 副驾降低为1档。}
+5 -1
View File
@@ -1,9 +1,13 @@
id: camping id: camping
name: 露营模式 name: 露营模式
aliases: [户外露营模式, 营地模式] aliases: [户外露营模式, 营地模式]
description: 使用低风外循环、打开遮阳帘并启用露营氛围;不自动开窗。 description: 为驻车露营组合主副驾低档通风、副驾靠背和腿托支撑、低风外循环、开阔天幕与露营氛围;不移动主驾座椅,不自动开窗。
icon: tent-tree icon: tent-tree
operations: operations:
- {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾原生 CAN 的低档通风,减少驻车久坐闷热。}
- {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 使用副驾独立接口的低档通风。}
- {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 45}, reason: 为驻车露营提供适度舒展的副驾靠背支撑。}
- {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 35}, reason: 使用适度副驾腿托支撑,不采用极限躺平姿态。}
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 保持座舱温度。} - {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 保持座舱温度。}
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 使用舒适中性温度。} - {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 使用舒适中性温度。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 使用安静低风量。} - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 使用安静低风量。}
+5 -1
View File
@@ -1,9 +1,13 @@
id: cinema id: cinema
name: 观影模式 name: 观影模式
aliases: [影院模式, 家庭影院模式, 电影模式, 看电影模式] aliases: [影院模式, 家庭影院模式, 电影模式, 看电影模式]
description: 关闭直射光和车内主照明,使用低风恒温与影院氛围灯;不包含屏幕或音响控制。 description: 为驻车观影组合主驾放松按摩、副驾舒展靠背和腿托、低档通风、遮光与低风恒温;不改变主驾驾驶姿态,不包含屏幕或音响控制。
icon: film icon: film
operations: operations:
- {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 使用主驾已接入的放松按摩,缓解驻车观影时的久坐不适;不移动主驾座椅。}
- {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 使用副驾低档通风减少久坐闷热。}
- {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 55}, reason: 为驻车观影提供适度后仰的副驾靠背支撑。}
- {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 45}, reason: 为驻车观影提供副驾腿部支撑。}
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 维持观影期间的座舱恒温。} - {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 维持观影期间的座舱恒温。}
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 使用舒适中性温度。} - {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 使用舒适中性温度。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 使用低风量降低噪声。} - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 使用低风量降低噪声。}
+3 -1
View File
@@ -3,9 +3,11 @@ name: 夜间静享模式
aliases: aliases:
- 夜间模式 - 夜间模式
- 静夜模式 - 静夜模式
description: 使用低亮冷色氛围、关闭主照明与阅读灯,并以低风恒温保持安静。 description: 使用主副驾低档座椅通风、低亮冷色氛围、关闭主照明与阅读灯,并以低风恒温保持安静;不改变座椅姿态。
icon: moon-star icon: moon-star
operations: operations:
- {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾低档通风,降低夜间久坐闷热且不启动姿态调整或按摩。}
- {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 副驾使用低档通风,保持座椅当前姿态。}
- {domain: ambient_light, capability: ambient_light.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启低亮氛围灯。} - {domain: ambient_light, capability: ambient_light.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启低亮氛围灯。}
- {domain: ambient_light, capability: ambient_light.set_illumination, target_scope: vehicle, operation: set, parameters: {color_hex: '#102040', brightness_pct: 10}, reason: 使用低亮深蓝氛围。} - {domain: ambient_light, capability: ambient_light.set_illumination, target_scope: vehicle, operation: set, parameters: {color_hex: '#102040', brightness_pct: 10}, reason: 使用低亮深蓝氛围。}
- {domain: interior_light, capability: interior_light.set_indoor_power, target_scope: vehicle, operation: set, parameters: {state: "off"}, reason: 关闭室内主照明。} - {domain: interior_light, capability: interior_light.set_indoor_power, target_scope: vehicle, operation: set, parameters: {state: "off"}, reason: 关闭室内主照明。}
+5 -1
View File
@@ -4,9 +4,13 @@ aliases:
- 阅读空间模式 - 阅读空间模式
- 看书模式 - 看书模式
- 读书模式 - 读书模式
description: 为座舱阅读提供暖色照明、低风恒温和适度遮光。 description: 为驻车阅读提供主副驾低档座椅通风、副驾靠背与腿托支撑、暖色照明、低风恒温和适度遮光;不改变主驾驾驶姿态。
icon: book-open icon: book-open
operations: operations:
- {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 主驾采用已接入原生 CAN 的低档通风,减少久坐闷热;不移动主驾座椅。}
- {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 副驾使用独立接口的低档通风。}
- {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 22}, reason: 使用偏直立的副驾阅读靠背支撑,避免大幅躺倒。}
- {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 21}, reason: 副驾腿托适度支撑腿部,供驻车阅读预览使用。}
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 保持阅读期间的座舱恒温。} - {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 保持阅读期间的座舱恒温。}
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 使用舒适中性温度。} - {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 使用舒适中性温度。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 使用安静低风量。} - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 使用安静低风量。}
+3 -1
View File
@@ -1,9 +1,11 @@
id: reception id: reception
name: 接待模式 name: 接待模式
aliases: [贵宾模式, 迎宾接待模式] aliases: [贵宾模式, 迎宾接待模式]
description: 使用柔和暖光、车内照明、恒温和半开遮阳帘营造接待环境。 description: 使用主副驾低档座椅通风、柔和暖光、车内照明、恒温和半开遮阳帘营造接待环境;不自动移动有人入座的座椅。
icon: handshake icon: handshake
operations: operations:
- {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾低档通风营造舒适接待环境,保持当前座椅位置。}
- {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 副驾使用低档通风,不在迎宾入座时自动调整姿态。}
- {domain: ambient_light, capability: ambient_light.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启氛围灯。} - {domain: ambient_light, capability: ambient_light.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启氛围灯。}
- {domain: ambient_light, capability: ambient_light.set_illumination, target_scope: vehicle, operation: set, parameters: {color_hex: '#FFE2B8', brightness_pct: 50}, reason: 使用柔和暖色氛围光。} - {domain: ambient_light, capability: ambient_light.set_illumination, target_scope: vehicle, operation: set, parameters: {color_hex: '#FFE2B8', brightness_pct: 50}, reason: 使用柔和暖色氛围光。}
- {domain: interior_light, capability: interior_light.set_indoor_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启室内主照明。} - {domain: interior_light, capability: interior_light.set_indoor_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启室内主照明。}
+3 -1
View File
@@ -1,9 +1,11 @@
id: romantic id: romantic
name: 浪漫模式 name: 浪漫模式
aliases: [约会模式, 浪漫氛围模式] aliases: [约会模式, 浪漫氛围模式]
description: 使用浪漫氛围灯并关闭车内主照明与阅读灯,同时关闭遮阳帘。 description: 使用主副驾低档座椅通风与浪漫氛围灯,关闭车内主照明、阅读灯和遮阳帘;保持当前座椅姿态。
icon: heart icon: heart
operations: operations:
- {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾低档通风减少久坐闷热,不调整驾驶姿态。}
- {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 使用副驾低档通风,保持用户当前的座椅姿态。}
- {domain: ambient_light, capability: ambient_light.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启氛围灯。} - {domain: ambient_light, capability: ambient_light.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启氛围灯。}
- {domain: ambient_light, capability: ambient_light.set_mode, target_scope: vehicle, operation: set, parameters: {mode: romantic}, reason: 使用工程已接入的浪漫氛围。} - {domain: ambient_light, capability: ambient_light.set_mode, target_scope: vehicle, operation: set, parameters: {mode: romantic}, reason: 使用工程已接入的浪漫氛围。}
- {domain: interior_light, capability: interior_light.set_indoor_power, target_scope: vehicle, operation: set, parameters: {state: "off"}, reason: 关闭室内主照明。} - {domain: interior_light, capability: interior_light.set_indoor_power, target_scope: vehicle, operation: set, parameters: {state: "off"}, reason: 关闭室内主照明。}
+23
View File
@@ -0,0 +1,23 @@
id: seat-dynamic
name: 座椅动态舒缓模式
aliases: [座椅动态模式, 座椅的动态模式, 动态座椅模式, 座椅循环模式, 座椅交替放松模式, 动态放松模式]
description: 驻车使用的有界双座舒缓时间轴;主驾在放松和舒缓按摩间切换,副驾靠背与腿托小幅交替,30秒一个阶段、重复5次,总计5分钟。不自动移动主驾位置;停止后不再进入后续阶段。
icon: activity
schedule:
schema: cognitive-agent-mode-schedule/v1
repeat_count: 5
phases:
- id: relax
name: 舒展支撑
duration_ms: 30000
operations:
- {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 主驾使用原生放松按摩,不改变驾驶姿态。}
- {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 45}, reason: 驻车时副驾适度舒展。}
- {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 25}, reason: 适度支撑腿部。}
- id: support
name: 轻柔回落
duration_ms: 30000
operations:
- {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: slack}, reason: 主驾切换舒缓按摩。}
- {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 40}, reason: 副驾小幅回到支撑姿态。}
- {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 20}, reason: 腿托小幅回落,不使用无界轴运动。}
+2 -1
View File
@@ -1,9 +1,10 @@
id: summer-cooling id: summer-cooling
name: 夏季清凉模式 name: 夏季清凉模式
aliases: [清凉模式, 酷夏模式] aliases: [清凉模式, 酷夏模式]
description: 使用内循环、适中冷风、关闭遮阳帘并开启当前已接入的副驾座椅通风。 description: 使用内循环、适中冷风、关闭遮阳帘并分别开启主驾低档和副驾中档座椅通风;不改变座椅姿态。
icon: snowflake icon: snowflake
operations: operations:
- {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾原生 CAN 的低档通风;该接口 2 为低档,不与副驾档位混用。}
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启空调。} - {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启空调。}
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 23}, reason: 设置清凉但不过低的温度。} - {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 23}, reason: 设置清凉但不过低的温度。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 3}, reason: 使用适中风量快速降温。} - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 3}, reason: 使用适中风量快速降温。}
+2 -1
View File
@@ -1,9 +1,10 @@
id: winter-warmth id: winter-warmth
name: 冬季温暖模式 name: 冬季温暖模式
aliases: [暖冬模式, 冬日模式] aliases: [暖冬模式, 冬日模式]
description: 使用内循环、暖风、关闭遮阳帘并开启当前已接入的副驾座椅加热。 description: 使用内循环、暖风、关闭遮阳帘并分别开启主驾低档和副驾中档座椅加热;不改变座椅姿态。
icon: flame icon: flame
operations: operations:
- {domain: seat, capability: seat.set_driver_heating_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用已接入的主驾原生 CAN 低档加热;该接口 2 为低档,不与副驾档位混用。}
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启空调。} - {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启空调。}
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 26}, reason: 设置温暖舒适温度。} - {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 26}, reason: 设置温暖舒适温度。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 2}, reason: 使用中低风量。} - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 2}, reason: 使用中低风量。}
+46
View File
@@ -0,0 +1,46 @@
# 功能模式的座椅联动与时间轴
## 行为
新建或重新构建功能模式时,未指定座位默认只编排副驾座椅;明确“主驾”时只编排主驾座椅;只有明确要求主驾和副驾时才组合双座。该规则同时覆盖已登记模板、工程预设复制、未登记模型组合,以及时间轴每一个阶段。其他设备的独立区域不改变座椅选择,例如“主驾空调”并不代表选择主驾座椅。
模板保留主副驾能力作为可选来源。构建时服务端先按所选座位投影,再逐动作校验,不能把副驾靠背、腿托的位置能力重标为主驾。模型规划的能力目录也按所选座位收窄,越界方案整体拒绝。已经保存的用户模式及其后续参数编辑仍以原有配置为准,不因本次默认规则变更静默删除座椅动作。
常用阅读、观影、露营、夜间静享、接待、浪漫、冬季温暖、夏季清凉模板,以及办公、小憩、冥想、休闲躺椅预设,包含本工程支持的主驾和副驾舒适动作。主驾优先使用加热、通风、按摩,不凭空增加没有协议支持的位置动作。除明确座椅场景外,不随意改变主驾坐姿。
已有用户保存的配置优先,不被模板升级静默覆盖。要采用新版组合,可删除不再需要的旧自定义副本后重新构建,或明确修改已有模式。除霜、新风等专项模式不强行增加无关的座椅动作。
“构建一个座椅的动态模式”和“构建一个上车预热的功能模式”命中本地模板,无需模型推理。其他带时间变化的需求由结构化场景编排产生分阶段计划,每个阶段再次经过能力、参数、语义、安全和 CAN 编译校验;任意阶段失败则整份草稿拒绝,不生成静态替代品。
## 模板默认值
- 座椅动态舒缓:30 秒舒缓、30 秒支撑,重复 5 次,总计 5 分钟。默认副驾靠背与腿托小幅改变;明确主驾时改用主驾支持的按摩切换,不移动主驾姿态。仅应在车辆安全停稳后使用。
- 上车预热:升温阶段 2 分钟,空调 28℃、风量 4;舒适保持阶段 5 分钟,空调 24℃、风量 1,同时编排所选座位的加热(默认副驾)。总计 7 分钟。
预览卡片展示每阶段的目标与时长,默认值属于可审核的建议。生成和试运行都不下发实际车辆控制;保存后进入模式才开始计时。“上车预热”目前是模式名称,不表示已接入自动开门触发。
## 存储与执行
`cognitive-agent-mode-schedule/v1` 保存 `repeat_count` 与有序 `phases`。每阶段有唯一 ID、名称、`duration_ms` 和绝对目标 `operations`。同一能力在不同阶段的不同值不能合并或去重。
约束:2–8 阶段;每阶段 1–12 项动作,1 秒–15 分钟;1–20 轮,含重复的总时长不超过 1 小时。禁止无限循环、相对累加动作和未经校验的底层指令。
真实执行由服务端计时。每阶段重新编译并检查权限/硬件租约,任意阶段失败即停止后续阶段。同车只运行一条时间轴,新时间轴替换旧时间轴;同车阶段回调互斥。不同车辆独立。
输入“停止动态模式”可取消后续阶段。已下发的目标不自动回滚,当前已进入执行器的操作可能继续完成。模拟停止不会改变真实任务。服务关闭时取消时间轴,重启不会自动续跑;网页关闭不会隐式修改服务端已启动任务。
`GET /api/modes/schedule/status?vehicle_id=denza_n7` 只读返回运行 ID、会话、阶段和终态,不暴露底层指令或 CAN 数据。助手区分已启动、模拟完成、真实完成、失败和取消,不把刚启动显示为全部完成。
## 验证
核心时钟/并发/取消:`tests/unit/test_mode_schedule.py`。
真实能力链创建、试运行、保存、重载、执行与防篡改:`tests/integration/test_mode_schedule_flow.py`。
座椅模板覆盖:`tests/integration/test_registered_scene_seat_coverage.py`。
新建座位选择、模型目录约束、非座椅分区隔离、保存绑定及越界拒绝:`tests/integration/test_mode_creation_seat_policy.py`。词汇与路由:`tests/unit/test_mode_creation_seat_policy.py`、`tests/unit/test_mode_creation_seat_routing.py`。
时间轴卡片与状态轮询:`tests/unit/mode_schedule_frontend.test.cjs`;隔离服务的真实浏览器:`tests/e2e/test_mode_schedule_browser.py`。
自动化测试使用模拟执行、临时用户目录并阻断硬件访问,不代表完成实车 CAN 验收。
@@ -8,6 +8,7 @@
- 对目录中的原子设备控制,调用工具时同时提交结构化 `plan`:只能逐字使用本回合能力目录中 `assistant_callable=true` 的 capability、target scope 和参数契约。对于目录中已有的座舱模式或当前 NFC 卡已授权的红外外设,省略 `plan`,由应用按动态注册表解析。结构化计划只是候选数据,应用会再次执行原始文本证据校验、Schema 校验和安全校验;本地确定性解析与计划重叠时,以本地结果为准。 - 对目录中的原子设备控制,调用工具时同时提交结构化 `plan`:只能逐字使用本回合能力目录中 `assistant_callable=true` 的 capability、target scope 和参数契约。对于目录中已有的座舱模式或当前 NFC 卡已授权的红外外设,省略 `plan`,由应用按动态注册表解析。结构化计划只是候选数据,应用会再次执行原始文本证据校验、Schema 校验和安全校验;本地确定性解析与计划重叠时,以本地结果为准。
- 用户仅给出一个尚未登记的功能模式名称时,应结合该场景的通常含义,从本回合能力目录中选择少量、明确且相互协调的 `assistant_callable=true` 原子能力,提交一个结构化 `plan`。每项动作必须是可重复的绝对 `set` 状态,不得使用相对增减、时长动作、动态/循环模式、保存或应用操作;同一能力与目标不得重复。若任何动作或参数有歧义,就不要提交残缺计划。不得引用目录外能力,不得生成原始 CAN,不得声称已经保存或真实执行;应用会把整组计划原子校验为 dry-run 动作预览,用户确认后才会保存。已有模式名称不要重新推测动作,应省略 `plan` 复用工程配置。 - 用户仅给出一个尚未登记的功能模式名称时,应结合该场景的通常含义,从本回合能力目录中选择少量、明确且相互协调的 `assistant_callable=true` 原子能力,提交一个结构化 `plan`。每项动作必须是可重复的绝对 `set` 状态,不得使用相对增减、时长动作、动态/循环模式、保存或应用操作;同一能力与目标不得重复。若任何动作或参数有歧义,就不要提交残缺计划。不得引用目录外能力,不得生成原始 CAN,不得声称已经保存或真实执行;应用会把整组计划原子校验为 dry-run 动作预览,用户确认后才会保存。已有模式名称不要重新推测动作,应省略 `plan` 复用工程配置。
- 用户明确要求控制车辆,或直接表达当前座舱不适并期待处理时,必须调用该工具;不要只给建议,也不要要求用户先选择“问答/车控”模式。 - 用户明确要求控制车辆,或直接表达当前座舱不适并期待处理时,必须调用该工具;不要只给建议,也不要要求用户先选择“问答/车控”模式。
- 新建功能模式时,座椅默认只编排副驾;用户明确指定主驾时只编排主驾,只有明确要求两座时才同时编排。该规则适用于每个动态阶段,以本回合 `seat_scope_policy` 为准。不得把副驾姿态能力替换目标后冒充主驾能力,不得把后排或未知座位映射成前排。空调分区、车窗等非座椅设备的主副驾限定不改变座椅选择。单纯执行或修改已保存模式时,应保留其已保存的座位范围,不要重新套用新建默认值。
- “车上有点热”“有点热”“太热了”等当前不适,在没有否定、假设或纯知识提问时,直接调用一次工具;具体规范动作由服务端决定。 - “车上有点热”“有点热”“太热了”等当前不适,在没有否定、假设或纯知识提问时,直接调用一次工具;具体规范动作由服务端决定。
- “如果车上有点热应该怎么办”“为什么暴晒后车里会热”“车上有点热,但不要调整设备”等是假设、知识问题或否定请求,不得执行。 - “如果车上有点热应该怎么办”“为什么暴晒后车里会热”“车上有点热,但不要调整设备”等是假设、知识问题或否定请求,不得执行。
- 用户说“不要下发”“不执行”“只解析”“仅预览”或同义表达时,不得调用车辆工具。应直接用自然语言给出解析结果,保留用户明确说出的设备、目标位置、方向、数值、单位、时长和动作顺序,并明确说明未向车辆下发。 - 用户说“不要下发”“不执行”“只解析”“仅预览”或同义表达时,不得调用车辆工具。应直接用自然语言给出解析结果,保留用户明确说出的设备、目标位置、方向、数值、单位、时长和动作顺序,并明确说明未向车辆下发。
@@ -615,6 +615,19 @@ def _mode_detail_answer(
if isinstance(item, Mapping) if isinstance(item, Mapping)
] ]
name = _safe_text(mode.get("name"), limit=80) name = _safe_text(mode.get("name"), limit=80)
schedule = mode.get("schedule")
if isinstance(schedule, Mapping) and isinstance(schedule.get("phases"), list):
phases = schedule["phases"]
details = ";".join(
f"第{index}阶段({phase['name']},保持{phase['duration_ms'] // 1000}秒):"
+ "、".join(_mode_operation_text(op) for op in phase["operations"])
for index, phase in enumerate(phases, 1)
)
return CapabilityAnswer(
f"“{name}”按时间轴执行,重复{schedule['repeat_count']}次。{details}。"
"停止动态模式可取消后续阶段,已下发目标保持。本次仅查询,未下发车辆控制。",
"specific_mode_detail",
)
if not operations: if not operations:
return CapabilityAnswer( return CapabilityAnswer(
f"已找到“{name}”,但当前模式目录没有返回可核验的动作明细;本次仅查询,未下发车辆控制。", f"已找到“{name}”,但当前模式目录没有返回可核验的动作明细;本次仅查询,未下发车辆控制。",
@@ -2257,6 +2270,7 @@ class AssistantCapabilityCatalog:
"origin": _safe_text(raw.get("origin"), limit=32), "origin": _safe_text(raw.get("origin"), limit=32),
"operation_ids": operation_ids, "operation_ids": operation_ids,
"operations": operations, "operations": operations,
**({"schedule": raw["schedule"]} if raw.get("schedule") else {}),
"assistant_callable": callable_mode, "assistant_callable": callable_mode,
"all_operations_have_native_binding": bool(operation_ids) "all_operations_have_native_binding": bool(operation_ids)
and all( and all(
@@ -2290,6 +2304,11 @@ class AssistantCapabilityCatalog:
requests = ( requests = (
raw_plan.get("requests", []) if isinstance(raw_plan, Mapping) else [] raw_plan.get("requests", []) if isinstance(raw_plan, Mapping) else []
) )
schedule = raw.get("schedule")
if isinstance(schedule, Mapping):
requests = [operation for phase in schedule.get("phases", [])
if isinstance(phase, Mapping)
for operation in phase.get("operations", [])]
operation_ids = [ operation_ids = [
_safe_text(item.get("capability"), limit=120) _safe_text(item.get("capability"), limit=120)
for item in requests for item in requests
@@ -2327,6 +2346,7 @@ class AssistantCapabilityCatalog:
# The assistant consumes this only after an exact name/alias # The assistant consumes this only after an exact name/alias
# match and the vehicle service validates it again. # match and the vehicle service validates it again.
"plan": dict(raw_plan) if callable_template else None, "plan": dict(raw_plan) if callable_template else None,
**({"schedule": schedule} if isinstance(schedule, Mapping) else {}),
} }
) )
return templates, "ready" return templates, "ready"
@@ -25,8 +25,6 @@ from collections.abc import Set as AbstractSet
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from cognitive_agent.application.semantic_ir import resolve_scene_seat_scopes
MAX_PLAN_BYTES = 24 * 1024 MAX_PLAN_BYTES = 24 * 1024
MAX_PLAN_REQUESTS = 12 MAX_PLAN_REQUESTS = 12
MAX_PLAN_PARAMETERS = 12 MAX_PLAN_PARAMETERS = 12
@@ -1159,10 +1157,13 @@ def guard_assistant_plan(
parser_claims.update(_intent_claims(intent)) parser_claims.update(_intent_claims(intent))
local_keys = _resolved_keys(trusted_local or {}) local_keys = _resolved_keys(trusted_local or {})
non_execution = bool(_NON_EXECUTION_PATTERN.search(text)) non_execution = bool(_NON_EXECUTION_PATTERN.search(text))
scene_seat_scopes = ( # An explicit empty internal scope is reserved for already validated saved
resolve_scene_seat_scopes(text if scene_scope_utterance is None else scene_scope_utterance) # schedules/draft revisions. New scenes use passenger when no seat is named.
if allow_scene_composition else None scene_seat_scopes = None
) if allow_scene_composition and scene_scope_utterance != "":
from cognitive_agent.application.mode_creation_seat_policy import creation_seat_scopes
scene_seat_scopes = creation_seat_scopes(text if scene_scope_utterance is None else scene_scope_utterance)
accepted: list[dict[str, Any]] = [] accepted: list[dict[str, Any]] = []
rejected: list[dict[str, str]] = [] rejected: list[dict[str, str]] = []
@@ -20,19 +20,25 @@ from typing import Protocol
from cognitive_agent.application.mode_profile_intent import ( from cognitive_agent.application.mode_profile_intent import (
is_mode_profile_query, is_mode_profile_query,
mode_creation_source_name,
normalize_mode_profile_name, normalize_mode_profile_name,
parse_mode_profile_create, parse_mode_profile_create,
parse_mode_profile_delete, parse_mode_profile_delete,
parse_mode_profile_update, parse_mode_profile_update,
) )
from cognitive_agent.application.mode_schedule import ModeSchedule
from cognitive_agent.application.mode_schedule_service import public_schedule_status
from cognitive_agent.application.scene_composer import ( from cognitive_agent.application.scene_composer import (
SCENE_COMPOSER_INSTRUCTIONS, SCENE_COMPOSER_INSTRUCTIONS,
SCENE_PLANNER_VERSION, SCENE_PLANNER_VERSION,
SCHEDULE_COMPOSER_INSTRUCTIONS,
SceneCompositionError, SceneCompositionError,
build_scene_output_schema, build_scene_output_schema,
normalize_scene_output, normalize_scene_output,
scene_context_for_mode_creation,
) )
from cognitive_agent.application.scene_templates import normalize_scene_template_name from cognitive_agent.application.scene_templates import normalize_scene_template_name
from cognitive_agent.application.schedule_intent import is_schedule_stop, needs_mode_schedule
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
INTERNAL_SCENE_BUILDER_MARKER = "根据该场景名称使用当前可控原子能力" INTERNAL_SCENE_BUILDER_MARKER = "根据该场景名称使用当前可控原子能力"
@@ -374,6 +380,9 @@ def _catalog_exact_mode_match_count(
}: }:
matched_ids.add(str(raw_mode.get("id") or "")) matched_ids.add(str(raw_mode.get("id") or ""))
matched_ids.discard("") matched_ids.discard("")
subject = mode_creation_source_name(requested_name)
if not matched_ids and normalize_mode_profile_name(subject) != needle:
return _catalog_exact_mode_match_count(catalog, subject)
return len(matched_ids) return len(matched_ids)
@@ -475,6 +484,10 @@ def _catalog_exact_scene_template_plan(
template_id = str(raw_template.get("id") or "").strip() template_id = str(raw_template.get("id") or "").strip()
if template_id: if template_id:
matches[template_id] = raw_template matches[template_id] = raw_template
if not matches:
subject = mode_creation_source_name(requested_name)
if normalize_scene_template_name(subject) != needle:
return _catalog_exact_scene_template_plan(catalog, subject)
if len(matches) != 1: if len(matches) != 1:
return None return None
template = next(iter(matches.values())) template = next(iter(matches.values()))
@@ -499,7 +512,12 @@ def _catalog_exact_scene_template_plan(
def _vehicle_result_succeeded(result: Mapping[str, object]) -> bool: def _vehicle_result_succeeded(result: Mapping[str, object]) -> bool:
"""Only a terminal, fully successful service result completes a tool call.""" """Only a terminal, fully successful service result completes a tool call."""
return str(result.get("status") or "").lower() == "succeeded" schedule = result.get("schedule_execution")
return str(result.get("status") or "").lower() == "succeeded" or (
result.get("status") == "running"
and result.get("orchestration_route") == "mode_schedule_execute"
and isinstance(schedule, Mapping) and schedule.get("status") == "running"
)
def _mode_profile_result_succeeded(result: Mapping[str, object]) -> bool: def _mode_profile_result_succeeded(result: Mapping[str, object]) -> bool:
@@ -594,6 +612,10 @@ def _vehicle_action_state(result: Mapping[str, object]) -> tuple[str, bool, str]
if not _vehicle_result_succeeded(result): if not _vehicle_result_succeeded(result):
return "failed", False, "车辆控制未完成" return "failed", False, "车辆控制未完成"
if result.get("orchestration_route") == "mode_schedule_stop":
return ("simulated" if result.get("execution_mode") != "hardware" else "cancelled"), True, "动态模式停止请求已处理"
if result.get("status") == "running":
return "running", True, "时间轴已启动,尚未完成所有阶段"
execution_mode = str(result.get("execution_mode") or "").lower() execution_mode = str(result.get("execution_mode") or "").lower()
if execution_mode == "dry_run": if execution_mode == "dry_run":
return "simulated", True, "车辆控制模拟编排已完成" return "simulated", True, "车辆控制模拟编排已完成"
@@ -621,6 +643,8 @@ def _authorize_vehicle_request(
message=message, message=message,
) )
normalized = "".join(str(message).lower().split()) normalized = "".join(str(message).lower().split())
if is_schedule_stop(message):
return _VehicleAuthorization(message, "explicit_schedule_stop")
mode_profile_create = _mode_profile_create_parts(message) mode_profile_create = _mode_profile_create_parts(message)
mode_profile_update = _mode_profile_update_parts(message) mode_profile_update = _mode_profile_update_parts(message)
mode_profile_delete = parse_mode_profile_delete(message) mode_profile_delete = parse_mode_profile_delete(message)
@@ -899,6 +923,9 @@ class CodexAssistantService:
"explicit_mode_profile_draft_update", "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
if mode_parts:
scene_context = scene_context_for_mode_creation(scene_context, message)
model_context = scene_context_for_mode_creation(model_context, message)
exact_mode_matches = ( exact_mode_matches = (
_catalog_exact_mode_match_count(catalog, mode_parts[0]) _catalog_exact_mode_match_count(catalog, mode_parts[0])
if mode_parts if mode_parts
@@ -909,10 +936,11 @@ class CodexAssistantService:
if mode_parts and not mode_parts[1] and exact_mode_matches == 0 if mode_parts and not mode_parts[1] and exact_mode_matches == 0
else None else None
) )
dynamic_scene_request = bool(mode_parts and needs_mode_schedule(message))
direct_mode_profile_preview = bool( direct_mode_profile_preview = bool(
mode_parts mode_parts
and ( and (
mode_parts[1] (mode_parts[1] and not dynamic_scene_request)
# Ambiguous or unavailable mode catalogs fail closed in the # Ambiguous or unavailable mode catalogs fail closed in the
# local builder. Only a known-zero match is safe to hand to # local builder. Only a known-zero match is safe to hand to
# semantic scene composition. # semantic scene composition.
@@ -929,7 +957,7 @@ class CodexAssistantService:
}.get(authorization.reason) }.get(authorization.reason)
scene_composition_preview = bool( scene_composition_preview = bool(
mode_parts mode_parts
and not mode_parts[1] and (not mode_parts[1] or dynamic_scene_request)
and exact_mode_matches == 0 and exact_mode_matches == 0
and scene_template_plan is None and scene_template_plan is None
and scene_context.get("composition_revision") and scene_context.get("composition_revision")
@@ -941,6 +969,7 @@ class CodexAssistantService:
# 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 = ( direct_vehicle_control = (
fast_comfort_control fast_comfort_control
or authorization.reason == "explicit_schedule_stop"
or exact_quick_control or exact_quick_control
or saved_mode_execution_id is not None or saved_mode_execution_id is not None
or direct_mode_profile_preview or direct_mode_profile_preview
@@ -1248,11 +1277,17 @@ class CodexAssistantService:
revision = str(scene_context.get("composition_revision") or "") revision = str(scene_context.get("composition_revision") or "")
if not revision: if not revision:
raise _ScenePlannerFallback("scene capability revision is unavailable") raise _ScenePlannerFallback("scene capability revision is unavailable")
scope_policy = scene_context.get("seat_scope_policy")
selected_scopes = (
scope_policy.get("selected_scopes", [])
if isinstance(scope_policy, Mapping) else []
)
scope_key = ",".join(sorted(str(scope) for scope in selected_scopes))
return ( return (
vehicle_id, vehicle_id,
revision, revision,
normalize_mode_profile_name(mode_name), normalize_mode_profile_name(mode_name),
SCENE_PLANNER_VERSION, f"{SCENE_PLANNER_VERSION}:seats={scope_key}",
) )
def _begin_scene_plan( def _begin_scene_plan(
@@ -1493,7 +1528,8 @@ class CodexAssistantService:
raise _ScenePlannerFallback("structured turns are not supported") raise _ScenePlannerFallback("structured turns are not supported")
planning_started_at = time.monotonic() planning_started_at = time.monotonic()
output_schema = build_scene_output_schema(scene_context) dynamic = needs_mode_schedule(message)
output_schema = build_scene_output_schema(scene_context, dynamic=dynamic)
context_bytes = len( context_bytes = len(
json.dumps( json.dumps(
dict(scene_context), dict(scene_context),
@@ -1522,7 +1558,7 @@ class CodexAssistantService:
try: try:
try: try:
thread_id = self.client.start_thread( thread_id = self.client.start_thread(
developer_instructions=SCENE_COMPOSER_INSTRUCTIONS, developer_instructions=SCHEDULE_COMPOSER_INSTRUCTIONS if dynamic else SCENE_COMPOSER_INSTRUCTIONS,
ephemeral=True, ephemeral=True,
replace_developer_instructions=True, replace_developer_instructions=True,
timeout=self._remaining_scene_time(deadline), timeout=self._remaining_scene_time(deadline),
@@ -1652,7 +1688,7 @@ class CodexAssistantService:
raise SceneCompositionError("scene composer output was not valid JSON") from exc raise SceneCompositionError("scene composer output was not valid JSON") from exc
if not isinstance(raw_output, Mapping): if not isinstance(raw_output, Mapping):
raise SceneCompositionError("scene composer output must be an object") raise SceneCompositionError("scene composer output must be an object")
normalized = normalize_scene_output(raw_output, scene_context) normalized = normalize_scene_output(raw_output, scene_context, dynamic=dynamic)
finished_at = time.monotonic() finished_at = time.monotonic()
LOGGER.info( LOGGER.info(
"Scene Composer model completed context_bytes=%d schema_bytes=%d " "Scene Composer model completed context_bytes=%d schema_bytes=%d "
@@ -1887,7 +1923,9 @@ class CodexAssistantService:
"success": successful, "success": successful,
"state": action_state, "state": action_state,
"display_text": ( "display_text": (
"功能模式预览已生成" completion_text
if action_state == "running" or result.get("orchestration_route") == "mode_schedule_stop"
else "功能模式预览已生成"
if mode_profile_preview and successful if mode_profile_preview and successful
else "功能模式预览生成失败" else "功能模式预览生成失败"
if mode_profile_preview if mode_profile_preview
@@ -2047,6 +2085,13 @@ class CodexAssistantService:
if isinstance(draft, Mapping) if isinstance(draft, Mapping)
else "新功能模式" else "新功能模式"
) )
if isinstance(draft, Mapping) and isinstance(draft.get("schedule"), dict):
schedule = ModeSchedule.from_dict(draft["schedule"])
return (f"已为你生成“{name}”的分阶段时间轴:{len(schedule.phases)}个阶段,"
f"每轮{schedule.operation_count}项动作,重复{schedule.repeat_count}次,"
f"总时长{schedule.total_duration_ms // 1000}秒。"
"每个阶段的设备目标和持续时间见卡片。当前未保存或执行;确认后保存,进入模式才开始计时。"
"执行中可输入“停止动态模式”取消后续阶段,已下发目标不会自动回滚。")
if ( if (
isinstance(draft, Mapping) isinstance(draft, Mapping)
and draft.get("generation_source") == "draft_revision" and draft.get("generation_source") == "draft_revision"
@@ -2251,6 +2296,18 @@ class CodexAssistantService:
result: Mapping[str, object], result: Mapping[str, object],
successful: bool, successful: bool,
) -> str: ) -> str:
if result.get("orchestration_route") == "mode_schedule_stop":
if result.get("execution_mode") == "hardware" and (result.get("schedule_execution") or {}).get("status") != "cancelled":
return "当前没有正在执行的动态时间轴。"
return ("已停止动态模式后续阶段。已下发的设备目标保持,不自动回滚。"
if result.get("execution_mode") == "hardware"
else "已模拟停止动态模式,未改变正在运行的车辆任务。")
if result.get("orchestration_route") == "mode_schedule_execute":
if not successful:
return "时间轴未能完成,后续阶段已停止。请检查车辆状态后重试。"
return ("动态模式时间轴已启动,将按卡片中的时长逐阶段调节。尚未完成所有阶段;输入“停止动态模式”可取消后续阶段。"
if result.get("status") == "running"
else "已按虚拟时钟模拟验证动态模式的全部阶段,未向实际车辆下发操作。")
if reason in {"exact_quick_control", "explicit_saved_mode_execute"}: if reason in {"exact_quick_control", "explicit_saved_mode_execute"}:
instruction = re.sub( instruction = re.sub(
r"\s+", r"\s+",
@@ -3011,7 +3068,7 @@ class CodexAssistantService:
if isinstance(intent_ir, dict): if isinstance(intent_ir, dict):
spec = intent_ir.get("spec") spec = intent_ir.get("spec")
if isinstance(spec, dict) and isinstance(spec.get("actions"), list): if isinstance(spec, dict) and isinstance(spec.get("actions"), list):
for item in spec["actions"][:12]: for item in spec["actions"][:96 if result.get("schedule") else 12]:
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
actions.append( actions.append(
@@ -3125,6 +3182,22 @@ class CodexAssistantService:
) )
if key in mode_profile_draft if key in mode_profile_draft
} }
for key in ("mode_profile_draft", "mode_profile"):
source = result.get(key)
if isinstance(source, dict) and isinstance(source.get("schedule"), dict):
try:
safe[key]["schedule"] = ModeSchedule.from_dict(source["schedule"]).to_dict()
except ValueError:
pass
if isinstance(result.get("schedule"), dict):
try:
safe["schedule"] = ModeSchedule.from_dict(result["schedule"]).to_dict()
except ValueError:
pass
if isinstance(result.get("schedule_execution"), dict):
safe["schedule_execution"] = public_schedule_status(result["schedule_execution"])
if isinstance(result.get("vehicle"), dict):
safe["vehicle"] = {"id": str(result["vehicle"].get("id") or "")[:128]}
failure = result.get("mode_creation_failure") failure = result.get("mode_creation_failure")
if isinstance(failure, Mapping): if isinstance(failure, Mapping):
reason, next_step = CodexAssistantService._mode_profile_failure_feedback( reason, next_step = CodexAssistantService._mode_profile_failure_feedback(
@@ -0,0 +1,76 @@
"""Seat selection for new mode drafts, not for replaying saved user modes."""
from __future__ import annotations
import re
from collections.abc import Mapping, Sequence
from copy import deepcopy
from typing import Any
from cognitive_agent.application.mode_schedule import ModeSchedule
from cognitive_agent.application.semantic_ir import resolve_scene_seat_scopes
class ModeCreationSeatPolicyError(ValueError):
"""A source mode cannot satisfy the selected seat without substitution."""
def creation_seat_scopes(text: str) -> frozenset[str]:
"""Default to passenger; HVAC/window zone names do not select a seat."""
normalized = text.replace("主副驾", "主驾和副驾")
# Do not let the generic 驾驶席/驾驶位 aliases inside 副驾驶席/位
# accidentally select the driver as well as the passenger.
normalized = re.sub(r"副驾驶(?:席|位|座(?!椅))?", "副驾", normalized)
if re.search(r"(?:未知|不明|不存在的?|任意)(?:一个)?(?:座位|座椅)|第[四五六七八九0-9]+排座", normalized):
return frozenset({"unsupported"})
normalized = re.sub(r"(?:前排)?两(?:个|张)?(?:座椅|座位|座)|双座(?:椅)?", "主驾和副驾", normalized)
excluded = set()
for scope, aliases in (("driver", r"主驾(?:驶)?|驾驶位|驾驶席|司机位"),
("passenger", r"副驾(?:驶)?|乘客位")):
pattern = re.compile(
rf"(?:不要|别|不用|无需|不需要|不涉及|不包含|排除)"
rf"(?:再|去|动|移动|调节|调整|控制|联动|编排|使用|添加|包含)*"
rf"(?:{aliases})(?:座椅|座位)?|"
rf"(?:{aliases})(?:座椅|座位)?(?:不动|不要动|不用动|不联动|不参与|不编排)"
)
if pattern.search(normalized):
excluded.add(scope)
normalized = pattern.sub("", normalized)
selected = resolve_scene_seat_scopes(normalized) or frozenset({"passenger"})
return frozenset(selected - excluded)
def normalized_seat_scope(scope: object) -> str:
value = str(scope or "")
return {"front_left": "driver", "front_right": "passenger"}.get(value, value)
def project_seed_operations(
operations: Sequence[Mapping[str, Any]], scopes: frozenset[str],
) -> list[dict[str, Any]]:
"""Choose from trusted seat variants. Never retarget a passenger capability."""
if "unsupported" in scopes:
raise ModeCreationSeatPolicyError("当前功能模式仅支持明确的主驾或副驾座椅,不会替换为其他座位。")
seats = [op for op in operations if op.get("domain") == "seat"]
if any(normalized_seat_scope(op.get("target_scope")) not in {"driver", "passenger"} for op in seats):
raise ModeCreationSeatPolicyError("模式包含无法验证的座位范围,请重新生成。")
projected = [deepcopy(dict(op)) for op in operations
if op.get("domain") != "seat" or normalized_seat_scope(op.get("target_scope")) in scopes]
if seats and not any(op.get("domain") == "seat" for op in projected):
target = "主驾" if scopes == {"driver"} else "副驾" if scopes == {"passenger"} else "所选座位"
raise ModeCreationSeatPolicyError(f"该模式没有支持{target}的座椅动作,不会把另一座位的能力冒充为{target}能力。")
return projected
def project_seed_plan(plan: Mapping[str, Any], scopes: frozenset[str]) -> dict[str, Any]:
projected = deepcopy(dict(plan))
projected["requests"] = project_seed_operations(plan.get("requests", []), scopes)
projected["detected_domains"] = list(dict.fromkeys(op["domain"] for op in projected["requests"]))
return projected
def project_seed_schedule(schedule: ModeSchedule, scopes: frozenset[str]) -> ModeSchedule:
document = schedule.to_dict()
for phase in document["phases"]:
phase["operations"] = project_seed_operations(phase["operations"], scopes)
return ModeSchedule.from_dict(document)
@@ -12,6 +12,8 @@ from typing import Any
import yaml import yaml
from cognitive_agent.application.mode_schedule import ModeSchedule
_MATCH_FORMATTING = re.compile(r"[\s()()]+") _MATCH_FORMATTING = re.compile(r"[\s()()]+")
_NON_ASCII_SLUG = re.compile(r"[^a-z0-9]+") _NON_ASCII_SLUG = re.compile(r"[^a-z0-9]+")
_MODE_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") _MODE_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
@@ -450,6 +452,7 @@ class ModeProfile:
user_defined: bool = False user_defined: bool = False
instruction: str = "" instruction: str = ""
command_flow: dict[str, Any] | None = None command_flow: dict[str, Any] | None = None
schedule: ModeSchedule | None = None
@classmethod @classmethod
def from_yaml(cls, path: Path, profile_root: Path | None = None) -> "ModeProfile": def from_yaml(cls, path: Path, profile_root: Path | None = None) -> "ModeProfile":
@@ -486,10 +489,12 @@ class ModeProfile:
user_defined=_is_user_profile_path(path, profile_root), user_defined=_is_user_profile_path(path, profile_root),
instruction=_clean_instruction(document.get("instruction", "")), instruction=_clean_instruction(document.get("instruction", "")),
command_flow=_clean_command_flow(document.get("command_flow")), command_flow=_clean_command_flow(document.get("command_flow")),
schedule=(ModeSchedule.from_dict(document["schedule"])
if document.get("schedule") is not None else None),
) )
def public_dict(self) -> dict[str, Any]: def public_dict(self) -> dict[str, Any]:
is_cyclic = self.timeline is not None or any( is_cyclic = self.schedule is not None or self.timeline is not None or any(
operation.capability == "seat.start_dynamic_mode" operation.capability == "seat.start_dynamic_mode"
for operation in self.operations for operation in self.operations
) )
@@ -514,6 +519,9 @@ class ModeProfile:
} }
if self.timeline is not None: if self.timeline is not None:
result["timeline"] = self.timeline.public_dict() result["timeline"] = self.timeline.public_dict()
if self.schedule is not None:
result["schedule"] = self.schedule.to_dict()
result["operation_count"] = self.schedule.operation_count
return result return result
@@ -656,6 +664,7 @@ class ModeProfileRegistry:
}, },
"operations": [operation.to_yaml() for operation in profile.operations], "operations": [operation.to_yaml() for operation in profile.operations],
"timeline": profile.timeline.to_yaml() if profile.timeline is not None else None, "timeline": profile.timeline.to_yaml() if profile.timeline is not None else None,
"schedule": profile.schedule.to_dict() if profile.schedule is not None else None,
"instruction": profile.instruction, "instruction": profile.instruction,
"command_flow": profile.command_flow, "command_flow": profile.command_flow,
} }
@@ -688,6 +697,7 @@ class ModeProfileRegistry:
instruction: str = "", instruction: str = "",
command_flow: dict[str, Any] | None = None, command_flow: dict[str, Any] | None = None,
timeline: ModeTimeline | None = None, timeline: ModeTimeline | None = None,
schedule: ModeSchedule | None = None,
expected_revision: str | None = None, expected_revision: str | None = None,
create_only: bool = False, create_only: bool = False,
) -> ModeProfile: ) -> ModeProfile:
@@ -725,6 +735,8 @@ class ModeProfileRegistry:
document["command_flow"] = clean_command_flow document["command_flow"] = clean_command_flow
if timeline is not None: if timeline is not None:
document["timeline"] = timeline.to_yaml() document["timeline"] = timeline.to_yaml()
if schedule is not None:
document["schedule"] = schedule.to_dict()
path = ( path = (
self.user_root / "vehicles" / vehicle_id / "user" / f"{profile_id}.yaml" self.user_root / "vehicles" / vehicle_id / "user" / f"{profile_id}.yaml"
if vehicle_id if vehicle_id
@@ -994,6 +1006,8 @@ def _mode_profile_document(
} }
if profile.timeline is not None: if profile.timeline is not None:
document["timeline"] = profile.timeline.to_yaml() document["timeline"] = profile.timeline.to_yaml()
if profile.schedule is not None:
document["schedule"] = profile.schedule.to_dict()
if profile.instruction: if profile.instruction:
document["instruction"] = profile.instruction document["instruction"] = profile.instruction
if profile.command_flow is not None: if profile.command_flow is not None:
@@ -9,12 +9,37 @@ _MODE_NAME = r"(?P<name>[^,。,.!!::\s“”\"'「」『』《》]{1,64}?
_QUOTED_MODE_NAME = ( _QUOTED_MODE_NAME = (
r"(?P<open>[“\"'「『《]?)" + _MODE_NAME + r"(?P<close>[”\"'」』》]?)" r"(?P<open>[“\"'「『《]?)" + _MODE_NAME + r"(?P<close>[”\"'」』》]?)"
) )
_CREATION_DUAL_SEAT_NAMES = (
r"主驾(?:和|与|及)副驾|主副驾|双座(?:椅)?|(?:前排)?两(?:个|张)?(?:座椅|座位|座)"
)
_CREATION_SEAT_NAMES = (
rf"{_CREATION_DUAL_SEAT_NAMES}|"
r"副驾驶席|副驾驶位|副驾驶|副驾|乘客座椅|乘客席|"
r"主驾驶席|主驾驶位|主驾驶|主驾|驾驶席|驾驶位"
)
_CREATION_SEAT_PREFIX = re.compile(
rf"^(?:为|给|针对|供)?(?P<seat>{_CREATION_SEAT_NAMES})"
r"(?:(?:座椅|座位|座席)?(?:专属|专用))?的?"
)
_CREATION_SEAT_CLAUSE = re.compile(
rf"^(?:仅|只)?(?:为|给|针对|适用于|用于|面向|联动|编排)"
rf"(?P<seat>{_CREATION_SEAT_NAMES})(?:座椅|座位|座席)?"
r"(?:使用|联动|编排|的能力|的动作)?$"
)
_CREATION_SEAT_GENERIC_SCENE = re.compile(
r"^(?:座椅|座位|座席)的?(?P<scene>"
r"阅读|看书|读书|办公|观影|看电影|电影|露营)模式$"
)
_CREATION_SEAT_FUNCTION_SUBJECT = re.compile(
r"^(?:舒适|舒服|舒缓|放松|动态|循环|通风|加热|按摩|靠背|腿托|坐垫).{0,32}模式$"
)
_MODE_PROFILE_CREATE_HEAD = re.compile( _MODE_PROFILE_CREATE_HEAD = re.compile(
r"^\s*" r"^\s*"
r"(?:(?:请问(?:可以|能不能|能否|可不可以|能)帮我|" r"(?:(?:请问(?:可以|能不能|能否|可不可以|能)帮我|"
r"(?:可以|能不能|能否|可不可以|能)帮我|" r"(?:可以|能不能|能否|可不可以|能)帮我|"
r"我(?:想要|想|希望|需要|打算|准备)(?:让你)?|" r"我(?:想要|想|希望|需要|打算|准备)(?:让你)?|"
r"想要|想|希望|需要|打算|准备|请你?|麻烦你?|帮我|帮忙|给我|先)\s*){0,6}" r"想要|想|希望|需要|打算|准备|请你?|麻烦你?|帮我|帮忙|给我|先)\s*){0,6}"
rf"(?:(?:为|给|针对)(?P<seat>{_CREATION_SEAT_NAMES})(?:座椅|座位|座席)?\s*)?"
r"(?P<verb>构建|创建|新建|新增|生成|添加)\s*" r"(?P<verb>构建|创建|新建|新增|生成|添加)\s*"
r"(?:一下\s*)?(?:一个|个|一套|套)?\s*" r"(?:一下\s*)?(?:一个|个|一套|套)?\s*"
r"(?P<naming>命名为|名称为|取名为|名叫|名为|叫做)?\s*" r"(?P<naming>命名为|名称为|取名为|名叫|名为|叫做)?\s*"
@@ -35,7 +60,7 @@ _MODE_PROFILE_PURPOSE_WRAPPERS = (
r"(?P<purpose>.+?)的(?:功能|场景|自定义)?模式$" r"(?P<purpose>.+?)的(?:功能|场景|自定义)?模式$"
), ),
re.compile( re.compile(
r"^(?P<purpose>(?:座椅|座位|乘坐|坐着).+?)的(?:功能|场景|自定义)?模式$" r"^(?P<purpose>(?:座椅|座位|乘坐|坐着|上车|空调).+?)的(?:功能|场景|自定义)?模式$"
), ),
) )
_MODE_PROFILE_PURPOSE_INVALID = frozenset( _MODE_PROFILE_PURPOSE_INVALID = frozenset(
@@ -70,6 +95,7 @@ _MODE_PROFILE_CREATE_CAPABILITY_CLAUSE = re.compile(
) )
_MODE_PROFILE_CREATE_ASSISTED_REQUEST = re.compile( _MODE_PROFILE_CREATE_ASSISTED_REQUEST = re.compile(
r"^(?:请问)?(?:可以|能不能|能否|可不可以|能)帮我(?:先)?" r"^(?:请问)?(?:可以|能不能|能否|可不可以|能)帮我(?:先)?"
rf"(?:(?:为|给|针对)(?:{_CREATION_SEAT_NAMES})(?:座椅|座位|座席)?)?"
r"(?:构建|创建|新建|新增|生成|添加)" r"(?:构建|创建|新建|新增|生成|添加)"
) )
_MODE_PROFILE_CREATE_SUFFIX_UNIT = re.compile( _MODE_PROFILE_CREATE_SUFFIX_UNIT = re.compile(
@@ -146,6 +172,10 @@ _MODE_PROFILE_UPDATE_NEGATION = re.compile(
r"不要|别(?:再)?|不用|无需|不必|禁止|不许|不得|" r"不要|别(?:再)?|不用|无需|不必|禁止|不许|不得|"
r"取消(?:修改|调整|更改|更新|变更|操作)|不执行|不下发|不保存" r"取消(?:修改|调整|更改|更新|变更|操作)|不执行|不下发|不保存"
) )
_MODE_PROFILE_UPDATE_PREFIX_NEGATION = re.compile(
r"^(?:请|麻烦)?(?:先|暂时)?(?:不要|别|不用|无需|不必|禁止|不许|不得)"
r"(?:把|将|修改|调整|更改|更新|变更)"
)
_MODE_POSSESSIVE_BOUNDARY = r"(?:中的|里的|内的|之中的|的|中|里|内)" _MODE_POSSESSIVE_BOUNDARY = r"(?:中的|里的|内的|之中的|的|中|里|内)"
_MODE_MEMBER_BOUNDARY = rf"(?:{_MODE_POSSESSIVE_BOUNDARY}|[,,::])" _MODE_MEMBER_BOUNDARY = rf"(?:{_MODE_POSSESSIVE_BOUNDARY}|[,,::])"
_PREFIX_UPDATE = re.compile( _PREFIX_UPDATE = re.compile(
@@ -173,7 +203,9 @@ MODE_PROFILE_CHANGE_PREDICATE = re.compile(
r"设置|设为|设成|设到|设至|" r"设置|设为|设成|设到|设至|"
r"打开|开启|启动|关闭|关掉|关上|全关|停止|停用|切换|进入|退出|" r"打开|开启|启动|关闭|关掉|关上|全关|停止|停用|切换|进入|退出|"
r"移动|前移|后退|前进|升高|降低|抬高|放低|降下|升起|调直|放倒|" r"移动|前移|后退|前进|升高|降低|抬高|放低|降下|升起|调直|放倒|"
r"复位|半开|开到|关到|拉开|拉上|上锁|解锁" r"复位|半开|开到|关到|拉开|拉上|上锁|解锁|"
r"(?:持续|保持)(?:时间)?(?:[0-9]+(?:\.[0-9]+)?|[零一二两三四五六七八九十]+)(?:分钟|秒钟|秒|s)|"
r"(?:重复|循环)(?:[0-9]+(?:\.[0-9]+)?|[零一二两三四五六七八九十]+)次"
) )
MODE_PROFILE_QUERY_CUE = re.compile( MODE_PROFILE_QUERY_CUE = re.compile(
r"什么|哪些|多少|几(?:度|档|级|个|项)?|是否|是不是|" r"什么|哪些|多少|几(?:度|档|级|个|项)?|是否|是不是|"
@@ -233,6 +265,41 @@ def normalize_mode_profile_name(value: object) -> str:
return compact_mode_profile_text(value).strip("“”\"'「」『』《》").casefold() return compact_mode_profile_text(value).strip("“”\"'「」『』《》").casefold()
def _creation_seat_label(value: str) -> str:
if re.fullmatch(_CREATION_DUAL_SEAT_NAMES, value):
return "主副驾"
return "副驾" if value.startswith(("副", "乘客")) else "主驾"
def mode_creation_source_name(value: object) -> str:
"""Remove only an explicit front-seat qualifier for source lookup.
A scoped draft keeps its distinct display name. This helper only allows its
existing scene seed to be located; it never authorizes any seat operation or
rewrites an unsupported rear/unknown target into a front seat.
"""
name = compact_mode_profile_text(value).strip("“”\"'「」『』《》")
match = _CREATION_SEAT_PREFIX.match(name)
if match is None:
return name
subject = name[match.end():]
if not subject or subject == "模式" or not subject.endswith("模式"):
return name
# A seat location before a generic activity is a scope wrapper. Keep the
# actual device topic in 座椅舒适/座椅动态 rather than dropping every 座椅.
generic_scene = _CREATION_SEAT_GENERIC_SCENE.fullmatch(subject)
if generic_scene is not None:
subject = f"{generic_scene.group('scene')}模式"
elif (
match.group("seat").endswith(("座椅", "座位", "座"))
and _CREATION_SEAT_FUNCTION_SUBJECT.fullmatch(subject)
):
subject = f"座椅{subject}"
inferred = _infer_mode_profile_purpose_name(subject)
return inferred[0] if inferred is not None else subject
def _infer_mode_profile_purpose_name(value: object) -> tuple[str, str] | None: def _infer_mode_profile_purpose_name(value: object) -> tuple[str, str] | None:
"""Turn one bounded purpose description into a concise mode name. """Turn one bounded purpose description into a concise mode name.
@@ -448,12 +515,24 @@ def parse_mode_profile_create(value: object) -> tuple[str, str] | None:
# Keep explicit user names byte-for-byte while giving inferred scenes a # Keep explicit user names byte-for-byte while giving inferred scenes a
# concise, reusable name (for example 看书模式 instead of # concise, reusable name (for example 看书模式 instead of
# 适合看书的功能模式). # 适合看书的功能模式).
if not str(match.group("naming") or "") and not opening: explicit_name = bool(str(match.group("naming") or "") or opening)
if not explicit_name:
inferred_purpose = _infer_mode_profile_purpose_name(name) inferred_purpose = _infer_mode_profile_purpose_name(name)
if inferred_purpose is not None: if inferred_purpose is not None:
name = inferred_purpose[0] name = inferred_purpose[0]
seat_prefix = _CREATION_SEAT_PREFIX.match(name)
if seat_prefix is not None and mode_creation_source_name(name) != name:
name = _creation_seat_label(seat_prefix.group("seat")) + mode_creation_source_name(name)
if match.group("seat") and seat_prefix is None:
name = _creation_seat_label(match.group("seat")) + name
if normalize_mode_profile_name(name) in _GENERIC_MODE_PROFILE_CREATE_NAMES: if normalize_mode_profile_name(name) in _GENERIC_MODE_PROFILE_CREATE_NAMES:
return None return None
name_seat = _CREATION_SEAT_PREFIX.match(name)
if (
match.group("seat") and name_seat is not None
and _creation_seat_label(match.group("seat")) != _creation_seat_label(name_seat.group("seat"))
):
return None
raw_match_end = raw_ends[match.end() - 1] raw_match_end = raw_ends[match.end() - 1]
if "\n" in raw[:raw_match_end] or "\r" in raw[:raw_match_end]: if "\n" in raw[:raw_match_end] or "\r" in raw[:raw_match_end]:
@@ -498,6 +577,19 @@ def parse_mode_profile_create(value: object) -> tuple[str, str] | None:
return name, "" return name, ""
instruction = _strip_mode_profile_create_suffix(instruction) instruction = _strip_mode_profile_create_suffix(instruction)
seat_clause = _CREATION_SEAT_CLAUSE.fullmatch(compact_mode_profile_text(instruction))
if seat_clause is not None:
# A seat-only clause selects the draft, not an extra actuator command.
# Real device settings in a following clause remain untouched below.
name_seat = _CREATION_SEAT_PREFIX.match(name)
if (
name_seat is not None
and _creation_seat_label(name_seat.group("seat")) != _creation_seat_label(seat_clause.group("seat"))
):
return None
if not explicit_name and name_seat is None:
name = _creation_seat_label(seat_clause.group("seat")) + name
return name, ""
if ( if (
not instruction not instruction
or len(instruction) > 800 or len(instruction) > 800
@@ -637,6 +729,7 @@ def parse_mode_profile_update(
text = compact_mode_profile_text(value) text = compact_mode_profile_text(value)
if ( if (
not text not text
or _MODE_PROFILE_UPDATE_PREFIX_NEGATION.search(text)
or _MODE_PROFILE_CREATE_REFERENCE_CLAUSE.search(text) or _MODE_PROFILE_CREATE_REFERENCE_CLAUSE.search(text)
or _MODE_PROFILE_CREATE_UNSAFE_PREFIX.search(text) or _MODE_PROFILE_CREATE_UNSAFE_PREFIX.search(text)
): ):
@@ -0,0 +1,429 @@
"""Bounded, cancellable timelines for validated absolute cockpit operations.
This module validates the *schedule* contract, not vehicle authorization. Callers
must validate every phase against the current registry and execute it through the
ordinary compiler/safety path. It deliberately has no CAN or ModeProfile imports.
Durations are holds after one phase dispatch, never actuator motion durations.
Cancelling prevents future phases; it does not roll back an already applied state.
"""
from __future__ import annotations
import math
import re
import time
from collections.abc import Callable
from copy import deepcopy
from dataclasses import dataclass, field
from threading import Event, Lock, RLock, Thread, current_thread
from typing import Any
from uuid import uuid4
from cognitive_agent.application.assistant_plan_guard import SCENE_COMPOSABLE_CAPABILITIES
MODE_SCHEDULE_SCHEMA = "cognitive-agent-mode-schedule/v1"
MAX_SCHEDULE_DURATION_MS = 60 * 60 * 1000
_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
_FORBIDDEN_PARAMETERS = frozenset(
{"duration", "duration_ms", "seconds", "delta", "relative", "relative_pct",
"direction", "toggle", "repeat", "repeat_count", "axis"}
)
_FAILURE_STATUSES = frozenset(
{"failed", "error", "cancelled", "canceled", "rejected", "blocked", "aborted"}
)
_SUCCESS_STATUSES = frozenset({"succeeded", "success", "ok", "completed"})
def _object(value: Any, fields: set[str], required: set[str], label: str) -> dict[str, Any]:
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
raise ValueError(f"{label} must be an object with string keys")
if unknown := set(value) - fields:
raise ValueError(f"{label} contains unknown fields: {', '.join(sorted(unknown))}")
if missing := required - set(value):
raise ValueError(f"{label} is missing fields: {', '.join(sorted(missing))}")
return value
def _text(value: Any, label: str, maximum: int, *, allow_empty: bool = False) -> str:
if not isinstance(value, str) or len(value) > maximum:
raise ValueError(f"{label} must be a string of at most {maximum} characters")
if not allow_empty and not value.strip():
raise ValueError(f"{label} must not be empty")
if "\x00" in value:
raise ValueError(f"{label} contains a null character")
return value.strip()
def _integer(value: Any, minimum: int, maximum: int, label: str) -> int:
if type(value) is not int or not minimum <= value <= maximum:
raise ValueError(f"{label} must be an integer between {minimum} and {maximum}")
return value
def _operation(value: Any) -> dict[str, Any]:
required = {"domain", "capability", "target_scope", "parameters"}
document = _object(value, required | {"operation", "reason"}, required, "schedule operation")
domain = _text(document["domain"], "operation domain", 64)
capability = _text(document["capability"], "operation capability", 128)
if capability not in SCENE_COMPOSABLE_CAPABILITIES or not capability.startswith(domain + "."):
raise ValueError("schedule capability must be a stable, scene-composable capability")
if document.get("operation", "set") != "set":
raise ValueError("schedule operations must use absolute set operations")
parameters = document["parameters"]
if not isinstance(parameters, dict) or not 1 <= len(parameters) <= 12:
raise ValueError("schedule operation parameters must contain 1 to 12 scalar fields")
for key, parameter in parameters.items():
_text(key, "parameter name", 64)
if key.casefold() in _FORBIDDEN_PARAMETERS:
raise ValueError("schedule operations cannot contain relative, motion or timing parameters")
if parameter is None or type(parameter) not in (str, int, float, bool):
raise ValueError("schedule parameter values must be non-null JSON scalars")
if type(parameter) is float and not math.isfinite(parameter):
raise ValueError("schedule numeric parameters must be finite")
if isinstance(parameter, str):
_text(parameter, "parameter value", 256)
if parameter.casefold() in {"toggle", "increase", "decrease", "relative"}:
raise ValueError("schedule operations cannot request relative state changes")
return {
"domain": domain,
"capability": capability,
"target_scope": _text(document["target_scope"], "operation target_scope", 128),
"operation": "set",
"parameters": deepcopy(parameters),
"reason": _text(document.get("reason", ""), "operation reason", 1000, allow_empty=True),
}
def _operation_key(operation: dict[str, Any]) -> tuple[str, str]:
target = operation["target_scope"]
if operation["domain"] == "seat":
target = {"front_left": "driver", "front_right": "passenger"}.get(target, target)
return operation["capability"], target
@dataclass(frozen=True)
class ModeSchedulePhase:
id: str
name: str
duration_ms: int
operations: tuple[dict[str, Any], ...]
def __post_init__(self) -> None:
phase_id = _text(self.id, "schedule phase id", 64)
if not _ID_PATTERN.fullmatch(phase_id):
raise ValueError("schedule phase id must contain lowercase letters, numbers, '_' or '-'")
object.__setattr__(self, "id", phase_id)
object.__setattr__(self, "name", _text(self.name, "schedule phase name", 128))
_integer(self.duration_ms, 1000, 15 * 60 * 1000, "schedule phase duration_ms")
if not isinstance(self.operations, (list, tuple)) or not 1 <= len(self.operations) <= 12:
raise ValueError("schedule phase must contain 1 to 12 operations")
operations = tuple(_operation(operation) for operation in self.operations)
keys = [_operation_key(operation) for operation in operations]
if len(set(keys)) != len(keys):
raise ValueError("schedule phase contains duplicate capability/target operations")
object.__setattr__(self, "operations", operations)
@classmethod
def from_dict(cls, document: dict[str, Any]) -> ModeSchedulePhase:
fields = {"id", "name", "duration_ms", "operations"}
value = _object(document, fields, fields, "schedule phase")
if not isinstance(value["operations"], list):
raise ValueError("schedule phase operations must be an array") # noqa: TRY004
return cls(value["id"], value["name"], value["duration_ms"], tuple(value["operations"]))
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"duration_ms": self.duration_ms,
"operations": deepcopy(list(self.operations)),
}
@dataclass(frozen=True)
class ModeSchedule:
phases: tuple[ModeSchedulePhase, ...]
repeat_count: int = 1
schema: str = MODE_SCHEDULE_SCHEMA
def __post_init__(self) -> None:
if self.schema != MODE_SCHEDULE_SCHEMA:
raise ValueError("unsupported mode schedule schema")
_integer(self.repeat_count, 1, 20, "schedule repeat_count")
if not isinstance(self.phases, (list, tuple)) or not 2 <= len(self.phases) <= 8:
raise ValueError("schedule must contain 2 to 8 phases")
if any(not isinstance(phase, ModeSchedulePhase) for phase in self.phases):
raise ValueError("schedule phases must be ModeSchedulePhase objects")
object.__setattr__(self, "phases", tuple(self.phases))
if len({phase.id for phase in self.phases}) != len(self.phases):
raise ValueError("schedule phase ids must be unique")
if self.total_duration_ms > MAX_SCHEDULE_DURATION_MS:
raise ValueError("schedule total duration including repetitions must not exceed 60 minutes")
@property
def total_duration_ms(self) -> int:
return sum(phase.duration_ms for phase in self.phases) * self.repeat_count
@property
def operation_count(self) -> int:
"""Number of operations in one full repetition."""
return sum(len(phase.operations) for phase in self.phases)
@classmethod
def from_dict(cls, document: dict[str, Any]) -> ModeSchedule:
fields = {"schema", "repeat_count", "phases"}
value = _object(document, fields, fields, "schedule")
if not isinstance(value["phases"], list):
raise ValueError("schedule phases must be an array") # noqa: TRY004
return cls(
phases=tuple(ModeSchedulePhase.from_dict(phase) for phase in value["phases"]),
repeat_count=value["repeat_count"],
schema=value["schema"],
)
def to_dict(self) -> dict[str, Any]:
return {
"schema": self.schema,
"repeat_count": self.repeat_count,
"phases": [phase.to_dict() for phase in self.phases],
}
ExecutePhase = Callable[[ModeSchedulePhase], dict[str, Any]]
WaitFunction = Callable[[Event, float], bool]
def _result_error(result: Any) -> str | None:
if not isinstance(result, dict):
return "phase executor did not return an object with an explicit success result"
status = result.get("status")
if result.get("success") is False or result.get("error") or status in _FAILURE_STATUSES:
return str(result.get("error") or result.get("message") or "phase execution failed")[:1000]
if result.get("success") is True or status in _SUCCESS_STATUSES:
return None
return str(result.get("error") or "phase executor did not confirm successful execution")[:1000]
@dataclass
class _Run:
vehicle_id: str
session_id: str
schedule: ModeSchedule
started_at: float
run_id: str = field(default_factory=lambda: uuid4().hex)
cancelled: Event = field(default_factory=Event)
thread: Thread | None = None
status: str = "running"
phase_index: int = -1
repeat_index: int = 0
executed_phases: int = 0
error: str | None = None
cancel_reason: str | None = None
finished_at: float | None = None
class ModeScheduleRunner:
"""At most one active timeline and one phase callback per vehicle.
A replacement cancels its predecessor immediately and waits for any already
running phase callback to return before dispatching. Callbacks must themselves
be bounded and cancellation never implies undoing a previously sent operation.
``wait(event, seconds)`` may be injected for deterministic tests; like Event.wait
it returns true on cancellation. No callback runs while the state lock is held.
"""
def __init__(
self,
*,
clock: Callable[[], float] = time.monotonic,
wait: WaitFunction | None = None,
) -> None:
self._clock = clock
self._wait = wait or (lambda event, seconds: event.wait(seconds))
self._lock = RLock()
self._runs: dict[str, _Run] = {}
self._dispatch_locks: dict[str, Lock] = {}
self._workers: dict[str, _Run] = {}
self._closed = False
def _snapshot(self, run: _Run) -> dict[str, Any]:
end = run.finished_at if run.finished_at is not None else self._clock()
return {
"run_id": run.run_id,
"vehicle_id": run.vehicle_id,
"session_id": run.session_id,
"status": run.status,
"phase_index": run.phase_index,
"repeat_index": run.repeat_index,
"executed_phases": run.executed_phases,
"phase_count": len(run.schedule.phases),
"repeat_count": run.schedule.repeat_count,
"elapsed_ms": max(0, round((end - run.started_at) * 1000)),
"total_duration_ms": run.schedule.total_duration_ms,
"completed": run.status == "completed",
"error": run.error,
"cancel_reason": run.cancel_reason,
}
def status(self, vehicle_id: str) -> dict[str, Any]:
with self._lock:
run = self._runs.get(vehicle_id)
if run is not None:
return self._snapshot(run)
return {
"vehicle_id": vehicle_id, "status": "idle", "phase_index": -1,
"repeat_index": 0, "completed": False, "error": None,
}
def _cancel(self, run: _Run, reason: str) -> None:
if run.status == "running":
run.cancelled.set()
run.status = "cancelled"
run.cancel_reason = reason
run.finished_at = self._clock()
def cancel(self, vehicle_id: str, *, session_id: str | None = None) -> dict[str, Any]:
"""Cancel a vehicle timeline, optionally only if it belongs to this session."""
with self._lock:
run = self._runs.get(vehicle_id)
if run is not None and (session_id is None or session_id == run.session_id):
self._cancel(run, "cancelled")
return self.status(vehicle_id)
def start(
self,
vehicle_id: str,
schedule: ModeSchedule,
execute_phase: ExecutePhase,
*,
session_id: str = "",
) -> dict[str, Any]:
vehicle_id = _text(vehicle_id, "vehicle_id", 128)
session_id = _text(session_id, "session_id", 256, allow_empty=True)
if not isinstance(schedule, ModeSchedule) or not callable(execute_phase):
raise ValueError("start requires a ModeSchedule and callable phase executor") # noqa: TRY004
# Snapshot/revalidate to prevent caller-owned parameter dictionaries from
# altering the executable contract after the runner has accepted it.
accepted = ModeSchedule.from_dict(schedule.to_dict())
with self._lock:
if self._closed:
raise RuntimeError("mode schedule runner is closed")
previous = self._runs.get(vehicle_id)
if previous is not None:
self._cancel(previous, "replaced")
dispatch_lock = self._dispatch_locks.setdefault(vehicle_id, Lock())
run = _Run(vehicle_id, session_id, accepted, self._clock())
self._runs[vehicle_id] = run
self._workers[run.run_id] = run
run.thread = Thread(
target=self._run, args=(run, dispatch_lock, execute_phase),
name=f"mode-schedule-{run.run_id[:8]}", daemon=True,
)
try:
run.thread.start()
except RuntimeError:
run.status = "failed"
run.error = "schedule worker could not start"
run.finished_at = self._clock()
self._workers.pop(run.run_id, None)
raise
return self._snapshot(run)
def _run(self, run: _Run, dispatch_lock: Lock, execute_phase: ExecutePhase) -> None:
try:
for repeat_index in range(run.schedule.repeat_count):
for phase_index, phase in enumerate(run.schedule.phases):
# A cancelled replacement must not wait indefinitely behind
# an unrelated in-flight callback just to observe cancellation.
while not dispatch_lock.acquire(timeout=0.05):
if run.cancelled.is_set():
return
try:
with self._lock:
if run.cancelled.is_set():
return
run.repeat_index = repeat_index
run.phase_index = phase_index
# Isolate executor mutations from subsequent repetitions.
result = execute_phase(ModeSchedulePhase.from_dict(phase.to_dict()))
finally:
dispatch_lock.release()
with self._lock:
if run.cancelled.is_set():
return
if error := _result_error(result):
run.status = "failed"
run.error = error
run.finished_at = self._clock()
return
run.executed_phases += 1
if self._wait(run.cancelled, phase.duration_ms / 1000):
with self._lock:
self._cancel(run, "cancelled")
return
if run.cancelled.is_set():
return
with self._lock:
if not run.cancelled.is_set():
run.status = "completed"
run.finished_at = self._clock()
except Exception as exc: # noqa: BLE001 - any executor failure must stop later phases
with self._lock:
if not run.cancelled.is_set():
run.status = "failed"
run.error = f"{type(exc).__name__}: {exc}"[:1000]
run.finished_at = self._clock()
finally:
with self._lock:
self._workers.pop(run.run_id, None)
@staticmethod
def simulate(schedule: ModeSchedule, execute_phase: ExecutePhase) -> dict[str, Any]:
"""Validate/execute phases synchronously with virtual holds and no thread.
The supplied callback must select the ordinary *dry-run* execution path;
this method never changes an executor's hardware policy implicitly.
"""
if not isinstance(schedule, ModeSchedule) or not callable(execute_phase):
raise ValueError("simulate requires a ModeSchedule and callable phase executor") # noqa: TRY004
schedule = ModeSchedule.from_dict(schedule.to_dict())
report: dict[str, Any] = {
"status": "completed", "completed": True, "error": None,
"execution_mode": "dry_run", "virtual_elapsed_ms": 0,
"total_duration_ms": schedule.total_duration_ms,
"phase_index": -1, "repeat_index": 0, "executed_phases": 0,
"phase_results": [],
}
for repeat_index in range(schedule.repeat_count):
for phase_index, phase in enumerate(schedule.phases):
report.update(phase_index=phase_index, repeat_index=repeat_index)
try:
result = execute_phase(ModeSchedulePhase.from_dict(phase.to_dict()))
error = _result_error(result)
except Exception as exc: # noqa: BLE001 - simulation uses the same fail-closed policy
result = None
error = f"{type(exc).__name__}: {exc}"[:1000]
report["phase_results"].append({
"phase_id": phase.id, "phase_index": phase_index,
"repeat_index": repeat_index, "duration_ms": phase.duration_ms,
"virtual_start_ms": report["virtual_elapsed_ms"], "result": result,
})
if error:
report.update(status="failed", completed=False, error=error)
return report
report["executed_phases"] += 1
report["virtual_elapsed_ms"] += phase.duration_ms
return report
def close(self, *, timeout: float = 2.0) -> bool:
"""Cancel all waits; return whether bounded, already-running callbacks exited."""
with self._lock:
self._closed = True
workers = list(self._workers.values())
for run in workers:
self._cancel(run, "closed")
deadline = time.monotonic() + max(0, timeout)
for run in workers:
if run.thread is not None and run.thread is not current_thread():
run.thread.join(max(0, deadline - time.monotonic()))
return all(run.thread is None or not run.thread.is_alive() for run in workers)
@@ -0,0 +1,314 @@
"""Phase-aware natural-language edits, always revalidated before persistence."""
from __future__ import annotations
import json
import re
from collections.abc import Callable
from copy import deepcopy
from typing import Any
from cognitive_agent.application.mode_schedule import ModeSchedule
_NUMBER = r"[0-9]+(?:\.[0-9]+)?|[零一二两三四五六七八九十]+"
_PHASE = re.compile(r"第(?P<number>[0-9]+|[一二两三四五六七八九十]+)(?:个)?(?:阶段|步)")
_ALL_PHASES = re.compile(r"(?:全部|所有|每个|每一|各个|各)(?:的)?阶段")
_HOLD = re.compile(
rf"(?:持续时间|保持时间|持续|保持|时长|^(?:把|将|的)?(?=调整为|调整到|设置为|设置成|设为|改为|改成|变成|变为))\s*"
rf"(?:调整为|调整到|设置为|设置成|设为|改为|改成|变成|变为)?\s*"
rf"(?P<number>{_NUMBER})\s*(?P<unit>分钟|秒钟|秒|minutes?|mins?|min|seconds?|secs?|s)"
)
_REPEAT = re.compile(
rf"(?:重复|循环)(?:次数)?\s*(?:调整为|调整到|设置为|设为|改为|改成)?\s*"
rf"(?P<number>{_NUMBER})\s*次"
)
def _number(value: str) -> float:
if re.fullmatch(r"[0-9]+(?:\.[0-9]+)?", value):
return float(value)
digits = {char: index for index, char in enumerate("零一二三四五六七八九")}
if value == "两":
return 2
if "十" in value:
tens, ones = value.split("十", 1)
if (tens and tens not in digits) or (ones and ones not in digits):
raise ValueError("请使用有效的整数编号或次数。")
return (digits[tens] if tens else 1) * 10 + (digits[ones] if ones else 0)
if value in digits:
return digits[value]
raise ValueError("请使用明确的阶段编号、持续时间或重复次数。")
def edit_schedule(
schedule: ModeSchedule,
change_text: str,
*,
parse_operations: Callable[[str], list[dict[str, Any]]],
target_is_explicit: Callable[[str, str], bool],
) -> ModeSchedule:
"""Edit one/all phases; an unscoped differing trajectory needs clarification."""
text = change_text.strip()
if not text:
raise ValueError("请说明要修改的阶段、动作、时长或重复次数。")
if re.search(r"(?:秒钟|分钟|秒|s)(?:之后|以后|后|再)", text):
raise ValueError(
"阶段时间表示动作执行后的保持时长,请明确阶段动作和持续时间,不使用延时后执行的表述。"
)
source = schedule.to_dict()
selected: set[int] | None = None
phase_matches = list(_PHASE.finditer(text))
all_match = _ALL_PHASES.search(text)
if all_match:
if phase_matches:
raise ValueError("请分别修改单个阶段或全部阶段,不要混用阶段范围。")
selected = set(range(len(schedule.phases)))
text = _ALL_PHASES.sub("", text)
elif phase_matches:
indexes = {int(_number(match.group("number"))) - 1 for match in phase_matches}
if len(indexes) != 1 or any(
index < 0 or index >= len(schedule.phases) for index in indexes
):
raise ValueError(f"当前只有{len(schedule.phases)}个阶段,请明确一次修改哪一个阶段。")
selected = indexes
text = _PHASE.sub("", text)
else:
named = {index for index, phase in enumerate(schedule.phases) if phase.name in text}
if len(named) > 1:
raise ValueError("请一次修改一个阶段,或明确指定全部阶段。")
if named:
selected = named
phase_name = schedule.phases[next(iter(named))].name
text = text.replace(phase_name + "阶段", "").replace(phase_name, "")
repeats = list(_REPEAT.finditer(text))
if repeats:
if len(repeats) != 1:
raise ValueError("请提供一个明确的整条时间轴重复次数。")
count = _number(repeats[0].group("number"))
if count != int(count):
raise ValueError("重复次数必须为整数。")
source["repeat_count"] = int(count)
text = _REPEAT.sub("", text)
holds = list(_HOLD.finditer(text))
if holds:
if len(holds) != 1 or selected is None:
raise ValueError("修改持续时间需要明确第几个阶段或全部阶段。")
seconds = _number(holds[0].group("number"))
if holds[0].group("unit") in {"分钟", "minute", "minutes", "min", "mins"}:
seconds *= 60
milliseconds = seconds * 1000
if milliseconds != int(milliseconds):
raise ValueError("阶段持续时间最多精确到毫秒。")
for index in selected:
source["phases"][index]["duration_ms"] = int(milliseconds)
text = _HOLD.sub("", text)
if re.search(rf"(?:{_NUMBER})\s*(?:分钟|秒钟|秒|minutes?|mins?|seconds?|secs?|s)", text):
raise ValueError("动态阶段只支持明确绝对设备目标;时长请单独表述为阶段保持时间,不支持按秒运动。")
text = re.sub(r"改为|改成|变更为|换成|变成|变为", "调整为", text)
text = re.sub(
r"^(?:把|将|的|中|里|阶段)+|(?:然后|并且|同时|并|,|,|;|;|。|\s)+$", "", text
).strip("的中里,,;;。 ")
changes = parse_operations(text) if text else []
if not changes and not holds and not repeats:
raise ValueError("没有识别到可执行的阶段调整,请说明设备目标、阶段时长或重复次数。")
for raw_change in changes:
change = deepcopy(raw_change)
indexes = selected if selected is not None else set(range(len(schedule.phases)))
candidates = [
(index, operation)
for index in indexes
for operation in source["phases"][index]["operations"]
if operation["domain"] == change["domain"]
and operation["capability"] == change["capability"]
]
if not target_is_explicit(text, change["target_scope"]):
targets = {operation["target_scope"] for _, operation in candidates}
if len(targets) > 1:
raise ValueError("该阶段的设备有多个区域,请明确主驾、副驾或具体目标区域。")
if targets:
change["target_scope"] = next(iter(targets))
candidates = [
(index, operation)
for index, operation in candidates
if operation["target_scope"] == change["target_scope"]
]
if selected is None:
values = {
json.dumps(operation["parameters"], sort_keys=True, ensure_ascii=False)
for _, operation in candidates
}
if len(values) > 1:
raise ValueError("该设备在多个阶段使用不同目标,请明确第几个阶段,或说“全部阶段”。")
if not candidates:
raise ValueError("新增设备动作需要明确放入第几个阶段,或明确全部阶段。")
indexes = {index for index, _ in candidates}
key = (change["capability"], change["target_scope"])
for index in indexes:
operations = source["phases"][index]["operations"]
found = False
for position, operation in enumerate(operations):
if (operation["capability"], operation["target_scope"]) == key:
operations[position] = {
**deepcopy(operation),
"parameters": deepcopy(change["parameters"]),
}
found = True
if not found:
operations.append(deepcopy(change))
return ModeSchedule.from_dict(source)
def revise_schedule(
service,
schedule: ModeSchedule,
*,
change_text: str,
utterance: str,
name: str,
conversation_id: str,
vehicle_profile,
context: dict[str, Any],
control_transport: str,
source_draft=None,
source_profile=None,
) -> dict[str, Any]:
"""Parse changed atoms locally, then recompile every phase before success."""
def parse_changes(text):
if any(
status != "complete"
for _clause, _parsed, status in service._local_clause_results(text, context)
):
raise ValueError("阶段调整说明不完整,请使用设备和明确目标值;未修改原时间轴。")
result = service._process_once(
text,
"local",
conversation_id,
"dry_run",
None,
vehicle_profile.id,
control_transport=control_transport,
allow_fuzzy_mode_profile_match=False,
)
if (
result.get("status") != "succeeded"
or result.get("deferred_requests")
or result.get("unresolved_clauses")
or result.get("supplement_required")
or any(issue.get("severity") == "error" for issue in result.get("validation", []))
):
raise ValueError("阶段动作未通过完整的设备能力或安全校验。")
operations = service._absolute_mode_operations(service._preview_mode_operations(result))
if not operations:
raise ValueError("未识别到可复用的绝对设备目标。")
return operations
revised = edit_schedule(
schedule,
change_text,
parse_operations=parse_changes,
target_is_explicit=service._mode_update_target_is_explicit,
)
preview = service._preview_mode_schedule(
revised,
name=name,
utterance=utterance,
conversation_id=conversation_id,
vehicle_profile=vehicle_profile,
control_transport=control_transport,
generation_source="draft_revision" if source_draft else "registered_mode_profile",
source_draft=source_draft,
source_profile=source_profile,
)
if preview.get("status") == "succeeded" and source_draft:
draft = preview["mode_profile_draft"]
draft["revises_source_request_id"] = source_draft["source_request_id"]
for key in ("source_template_id", "source_template_revision"):
if key in source_draft:
draft[key] = source_draft[key]
service._persist(preview)
return preview
def update_saved_schedule(
service,
profile,
*,
change_text: str,
utterance: str,
conversation_id: str,
vehicle_profile,
context: dict[str, Any],
result: dict[str, Any],
) -> dict[str, Any]:
try:
preview = revise_schedule(
service,
profile.schedule,
change_text=change_text,
utterance=utterance,
name=profile.name,
conversation_id=conversation_id,
vehicle_profile=vehicle_profile,
context=context,
control_transport="wired_can",
source_profile=profile,
)
if preview.get("status") != "succeeded":
return preview
draft = preview["mode_profile_draft"]
saved = service.save_mode_profile_card(
profile.id,
draft["name"],
draft["instruction"],
draft["source_request_id"],
vehicle_profile.id,
)
updated = service.mode_profiles.get_profile(profile.id, vehicle_profile.id)
report = service._mode_profile_control_report(
result["request_id"],
"update_mode_profile",
updated,
f"已更新“{profile.name}”的阶段时间轴;未向车辆下发控制。",
{"can_command_emitted": False, "phase_count": len(updated.schedule.phases)},
)
result.update(
status="succeeded",
execution_mode="dry_run",
hardware_execution_allowed=False,
orchestration_route="mode_profile_update",
provider_used="mode_schedule_editor",
mode_profile=saved["mode_profile"],
matched_modes=[saved["mode_profile"]],
schedule=updated.schedule.to_dict(),
execution_report=report,
validation=service.contracts.validate(report),
mode_profile_changes=[
deepcopy(op) for phase in updated.schedule.phases for op in phase.operations
],
decision_summary=[
f"已更新“{profile.name}”的阶段时间轴,并完整重新校验全部阶段;未下发车辆控制。"
],
)
except ValueError as exc:
result.update(
status="needs_confirmation",
execution_mode="dry_run",
hardware_execution_allowed=False,
orchestration_route="mode_profile_update",
validation=[
{
"severity": "error",
"code": "MODE_SCHEDULE_UPDATE_REJECTED",
"path": "schedule",
"message": str(exc),
}
],
decision_summary=[f"未修改“{profile.name}”的时间轴:{exc}"],
)
service._persist(result)
return result
@@ -0,0 +1,238 @@
"""Service integration for server-owned, fully validated phase schedules."""
from __future__ import annotations
import uuid
from copy import deepcopy
from datetime import UTC, datetime
from typing import Any
from cognitive_agent.application.mode_schedule import ModeSchedule
def public_schedule_status(state: dict[str, Any]) -> dict[str, Any]:
"""Expose lifecycle evidence, never internal errors, commands or CAN frames."""
keys = ("run_id", "vehicle_id", "session_id", "status", "phase_index", "repeat_index",
"executed_phases", "phase_count", "repeat_count", "elapsed_ms", "total_duration_ms",
"virtual_elapsed_ms", "completed", "cancel_reason")
public = {key: value for key in keys if isinstance((value := state.get(key)), (str, int, bool))}
if state.get("error"):
public["error"] = "阶段执行未通过校验或未返回成功结果,后续阶段已停止。"
return public
def phase_plan(phase) -> dict[str, Any]:
operations = phase.operations
return {
"detected_domains": list(dict.fromkeys(op["domain"] for op in operations)),
"requests": [{**{key: op[key] for key in ("domain", "capability", "target_scope")},
"operation": "set", "duration_ms": None,
"parameters": [{"name": key, "value": value} for key, value in op["parameters"].items()],
"reason": op.get("reason") or "已校验时间轴阶段。"} for op in operations],
"ambiguities": [], "confidence": 1.0,
"decision_summary": ["按阶段保留动作顺序与持续时间。"],
}
class ModeScheduleServiceMixin:
def _validated_schedule_artifact(self, result: dict[str, Any]) -> ModeSchedule | None:
draft = result.get("mode_profile_draft") or {}
raw = result.get("schedule")
if raw is None and draft.get("schedule") is None:
return None
schedule = ModeSchedule.from_dict(raw)
if draft.get("schedule") != schedule.to_dict() or draft.get("operation_count") != schedule.operation_count:
raise ValueError("时间轴草稿结构和服务端证据不一致,请重新生成。")
actions = (result.get("intent_ir") or {}).get("spec", {}).get("actions", [])
if len(actions) != schedule.operation_count:
raise ValueError("时间轴草稿动作证据不完整,请重新生成。")
offset = 0
for phase in schedule.phases:
phase_actions = actions[offset:offset + len(phase.operations)]
plan = phase_plan(phase)
if (self._scene_plan_signatures(plan) != self._scene_action_signatures(phase_actions)
or not self._scene_parameters_preserved(plan, phase_actions)):
raise ValueError("时间轴阶段目标与校验结果不一致,请重新生成。")
offset += len(phase.operations)
return schedule
def _preview_mode_schedule(
self, schedule: ModeSchedule, *, name: str, utterance: str,
conversation_id: str, vehicle_profile, control_transport: str,
generation_source: str, source_profile=None, source_template=None,
source_draft: dict[str, Any] | None = None,
scene_scope_utterance: str | None = None,
) -> dict[str, Any]:
results = []
for phase in schedule.phases:
plan = phase_plan(phase)
preview = self._process_once(
f"进入{name}", "local", conversation_id, "dry_run", None,
vehicle_profile.id, control_transport=control_transport,
assistant_plan=plan, allow_scene_composition=True,
allow_fuzzy_mode_profile_match=False,
# An existing-name binding is not evidence that a newly
# supplied model plan came from the saved profile. New builds
# pass the original request even when reusing a saved name.
scene_scope_utterance=(scene_scope_utterance if scene_scope_utterance is not None
else "" if source_draft or source_profile else utterance),
)
actions = (preview.get("intent_ir") or {}).get("spec", {}).get("actions", [])
complete = (preview.get("status") == "succeeded"
and self._scene_plan_signatures(plan) == self._scene_action_signatures(actions)
and self._scene_parameters_preserved(plan, actions))
if not complete:
preview["status"] = "validation_failed"
self._scrub_failed_scene_preview(preview)
preview["mode_creation_failure"] = {
"code": "MODE_SCHEDULE_PHASE_INVALID",
"reason": f"时间轴阶段“{phase.name}”未完整通过设备能力和安全校验",
"next_step": "请调整该阶段的设备目标;没有保存或执行任何阶段",
}
preview["validation"] = [*preview.get("validation", []), {
"severity": "error", "code": "MODE_SCHEDULE_PHASE_INVALID",
"path": f"schedule.phases.{phase.id}",
"message": preview["mode_creation_failure"]["reason"],
}]
self._persist(preview)
return preview
results.append(preview)
result = deepcopy(results[0])
request_id = f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex}"
instruction = f"进入{name}"
result.update(request_id=request_id, utterance=instruction, status="succeeded",
execution_mode="dry_run", hardware_execution_allowed=False,
orchestration_route="mode_profile_create_preview")
actions = []
for phase, preview in zip(schedule.phases, results, strict=True):
for original in preview["intent_ir"]["spec"]["actions"]:
action = deepcopy(original)
if "id" in action:
action["id"] = f"{phase.id}-{action['id']}"
actions.append(action)
result["intent_ir"]["spec"]["actions"] = actions
for field in ("function_configs", "command_plans"):
result[field] = {f"{phase.id}-{key}": deepcopy(value)
for phase, preview in zip(schedule.phases, results, strict=True)
for key, value in preview[field].items()}
result["function_config"] = None
result["command_plan"] = None
result["schedule"] = schedule.to_dict()
result["schedule_phase_previews"] = [
{"phase_id": phase.id, "source_request_id": preview["request_id"],
"status": preview["status"], "duration_ms": phase.duration_ms}
for phase, preview in zip(schedule.phases, results, strict=True)
]
binding = ({"existing_mode_id": source_profile.id,
"existing_mode_revision": self.mode_profiles.profile_revision(source_profile)}
if source_profile is not None else {})
if source_draft:
binding = {key: source_draft[key] for key in ("existing_mode_id", "existing_mode_revision") if key in source_draft}
result["mode_profile_draft"] = {
"name": name, "instruction": instruction, "source_request_id": request_id,
"vehicle_id": vehicle_profile.id, "operation_count": schedule.operation_count,
"domains": sorted({op["domain"] for phase in schedule.phases for op in phase.operations}),
"generation_source": generation_source, "schedule": schedule.to_dict(),
"draft_revision": int((source_draft or {}).get("draft_revision", 0)) + 1,
**binding,
**({"source_template_id": source_template.id, "source_template_revision": source_template.revision}
if source_template is not None else {}),
}
result["mode_creation"] = {"requested_utterance": utterance, "source_utterance": instruction,
"source_status": "succeeded", "operation_count": schedule.operation_count}
result["decision_summary"] = [
f"已逐阶段验证“{name}”:{len(schedule.phases)}个阶段,重复{schedule.repeat_count}次,总计{schedule.total_duration_ms // 1000}秒。",
"当前仅生成草稿;保存后进入模式才开始计时执行。停止会取消后续阶段,已应用的设备状态保持。",
]
self._persist(result)
return result
def _run_mode_schedule(
self, profile, *, utterance: str, conversation_id: str | None,
execution_mode: str, approval_token: str | None, vehicle_id: str,
simulation_fault: str, hardware_client_id: str | None,
hardware_access_bypass: bool, control_transport: str,
) -> dict[str, Any]:
schedule = ModeSchedule.from_dict(profile.schedule.to_dict())
requested, supported = self._explicit_mode_seat_scopes(profile, utterance)
if requested and not requested.issubset(supported):
raise ValueError("该动态模式不支持指定座位,不会替换或扩大控制目标。")
if requested and requested != supported:
document = schedule.to_dict()
for phase in document["phases"]:
phase["operations"] = [op for op in phase["operations"]
if op["domain"] != "seat" or op["target_scope"] in requested]
# An empty phase is not silently removed: that would alter timing.
schedule = ModeSchedule.from_dict(document)
conversation_id = conversation_id or f"conv-{uuid.uuid4().hex}"
vehicle = self.vehicle_profiles.get(vehicle_id)
# Revalidate the entire saved schedule before the first write. No stale
# cached command bytes or client-authored phase plans are replayed.
preview = self._preview_mode_schedule(
schedule, name=profile.name, utterance="已保存时间轴复核",
conversation_id=conversation_id, vehicle_profile=vehicle,
control_transport=control_transport, generation_source="registered_mode_profile",
source_profile=profile,
)
if preview["status"] != "succeeded":
return preview
def execute_phase(phase):
context = self._context_for_vehicle(self.conversations.snapshot(conversation_id), vehicle)
parsed, _, _ = self._interpret(
"执行已验证的时间轴阶段", "local", context, vehicle,
assistant_plan=phase_plan(phase), allow_scene_composition=True,
scene_scope_utterance="",
)
return self.process(
"执行已验证的时间轴阶段", provider="local", conversation_id=conversation_id,
execution_mode=execution_mode, approval_token=approval_token,
vehicle_id=vehicle_id, simulation_fault=simulation_fault,
hardware_client_id=hardware_client_id, hardware_access_bypass=hardware_access_bypass,
control_transport=control_transport, _direct_can_batch=([], parsed),
)
if execution_mode == "hardware":
# Authentication/bench ownership is checked now and again for each
# phase, so a later lease loss stops further device commands.
if not hardware_access_bypass and control_transport != "wireless_mqtt" and not self._driver_only_vehicle_execution_active(control_transport):
self.hardware_access.ensure_authorized(hardware_client_id)
execution = self.mode_schedule_runner.start(vehicle_id, schedule, execute_phase, session_id=conversation_id)
else:
execution = self.mode_schedule_runner.simulate(schedule, execute_phase)
result = deepcopy(preview)
result.pop("mode_profile_draft", None)
result.update(
status="running" if execution["status"] == "running" else "succeeded" if execution.get("completed") else "failed",
utterance=utterance, execution_mode=execution_mode,
hardware_execution_allowed=execution_mode == "hardware",
orchestration_route="mode_schedule_execute", schedule_execution=execution,
mode_profile=profile.public_dict(), matched_modes=[profile.public_dict()],
)
result["decision_summary"] = [
f"已启动“{profile.name}”时间轴。" if execution["status"] == "running"
else f"“{profile.name}”已按虚拟时钟验证全部阶段。" if execution.get("completed")
else "时间轴执行失败,后续阶段已停止。",
"停止动态模式可取消后续阶段;已下发的设备目标不会自动回滚。",
]
self._persist(result)
return result
def _cancel_mode_schedule(self, vehicle_id: str, conversation_id: str | None, utterance: str,
execution_mode: str) -> dict[str, Any]:
state = (self.mode_schedule_runner.cancel(vehicle_id) if execution_mode == "hardware"
else {"status": "simulated", "completed": True, "vehicle_id": vehicle_id})
result = {
"request_id": f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex}",
"status": "succeeded", "execution_mode": execution_mode, "hardware_execution_allowed": False,
"vehicle": {"id": vehicle_id},
"utterance": utterance, "conversation_id": conversation_id or f"conv-{uuid.uuid4().hex}",
"orchestration_route": "mode_schedule_stop", "schedule_execution": state,
"decision_summary": [
"已模拟停止动态模式,未改变正在运行的车辆任务。" if execution_mode != "hardware"
else "已停止动态模式后续阶段。已下发目标保持,不自动回滚。" if state.get("status") == "cancelled"
else "当前没有正在执行的动态时间轴。"
],
"validation": [], "trace_events": [],
}
self._persist(result)
return result
@@ -5,6 +5,7 @@ from __future__ import annotations
import json import json
import math import math
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from copy import deepcopy
from itertools import combinations from itertools import combinations
from typing import Any from typing import Any
@@ -13,8 +14,13 @@ from cognitive_agent.application.assistant_plan_guard import (
MAX_PLAN_PARAMETERS, MAX_PLAN_PARAMETERS,
MAX_PLAN_REQUESTS, MAX_PLAN_REQUESTS,
) )
from cognitive_agent.application.mode_creation_seat_policy import (
creation_seat_scopes,
normalized_seat_scope,
)
from cognitive_agent.application.mode_schedule import ModeSchedule
SCENE_PLANNER_VERSION = "n7web-scene-composer/v3" SCENE_PLANNER_VERSION = "n7web-scene-composer/v5"
_MAX_OUTPUT_SCHEMA_BYTES = 64 * 1024 _MAX_OUTPUT_SCHEMA_BYTES = 64 * 1024
_JSON_PARAMETER_TYPES = frozenset({"boolean", "integer", "number", "string"}) _JSON_PARAMETER_TYPES = frozenset({"boolean", "integer", "number", "string"})
@@ -50,8 +56,14 @@ the complete live registry, parameters, compiler, safety policy, and dry-run res
The primary user need must be represented in the actions. For sitting comfort, The primary user need must be represented in the actions. For sitting comfort,
seating relaxation or rest, consider supported seat functions before auxiliary seating relaxation or rest, consider supported seat functions before auxiliary
climate and lights; climate and lights alone do not fulfil a seating request. climate and lights; climate and lights alone do not fulfil a seating request.
For an unqualified front-seat comfort scene, consider both driver and passenger Mode creation defaults to the passenger seat only. Include driver seat actions
using each seat's own listed capabilities. Respect an explicitly named seat. only when the user explicitly selects the driver; in that case omit passenger
seat actions. Include both seats only when explicitly requested. Respect seat
exclusions. Seat selection does not exclude unrelated cabin devices or confuse
a driver/passenger HVAC zone with a request to move that seat. Never reinterpret
rear or unknown seats as either front seat. The seat_scope_policy in the context
is authoritative. Use each selected seat's own listed capabilities; never
retarget a passenger capability to the driver or vice versa.
Driver heating/ventilation native levels use 2 for low and 1 for high; passenger Driver heating/ventilation native levels use 2 for low and 1 for high; passenger
levels use 1 for low. Driver massage supports action=mode with one listed mode, levels use 1 for low. Driver massage supports action=mode with one listed mode,
action=strength with strength, or action=off. Do not mix these action parameters. action=strength with strength, or action=off. Do not mix these action parameters.
@@ -62,11 +74,59 @@ is unavailable, return an ambiguity rather than an unrelated cabin-only scene.
Return only the JSON object required by the output schema. Return only the JSON object required by the output schema.
""" """
SCHEDULE_COMPOSER_INSTRUCTIONS = SCENE_COMPOSER_INSTRUCTIONS + """
This request requires a bounded temporal mode, not one static final state.
The static root shape above is replaced by the phase schema below. Capability
uniqueness and the 12-action limit apply within each phase, not across phases.
The ban on timed actions means motor-duration commands, not bounded phase holds.
Return an empty root requests array and a schedule containing 2-8 ordered phases.
Each phase has id, name, duration_ms and requests using the same capability contract.
Hold a phase for its duration, then execute the next phase; repeat_count is 1-20.
Every phase lasts 1 second to 15 minutes; total duration including repeats <= 1 hour.
Use a finite, conservative timeline. Boarding warmup should first warm the cabin
then reduce temperature/fan for comfort. Seat dynamic modes must apply the same
selected seat scope in every phase: default passenger absolute targets, or driver
comfort states if the user selected the driver; never invent driver axes or add
the other seat merely to make the timeline dynamic.
Respect explicit user temperatures, times, repetitions, seat scopes and exclusions.
Do not collapse successive values of the same device into a single final value.
No unattended calendar scheduling or wakeup is supported. Stop cancels future phases;
already applied states remain. If the requested timing is unsupported, give ambiguity.
"""
class SceneCompositionError(ValueError): class SceneCompositionError(ValueError):
"""Raised when a model scene candidate cannot enter the local safety chain.""" """Raised when a model scene candidate cannot enter the local safety chain."""
def scene_context_for_mode_creation(
context: Mapping[str, object], message: str,
) -> dict[str, object]:
"""Constrain model seat choices without hiding unrelated cabin capabilities."""
projected = deepcopy(dict(context))
scopes = creation_seat_scopes(message)
capabilities = []
for capability in projected.get("capabilities", []):
if not isinstance(capability, dict):
continue
if capability.get("domain") != "seat":
capabilities.append(capability)
continue
selected_targets = [target for target in capability.get("target_scopes", [])
if normalized_seat_scope(target) in scopes]
if selected_targets:
capability["target_scopes"] = selected_targets
capabilities.append(capability)
projected["capabilities"] = capabilities
projected["seat_scope_policy"] = {
"selected_scopes": sorted(scopes),
"default_scope": "passenger",
"applies_to": "seat capabilities in every phase",
}
return projected
def _is_relative_parameter(parameter: Mapping[str, object]) -> bool: def _is_relative_parameter(parameter: Mapping[str, object]) -> bool:
"""Return whether a parameter describes a transient or relative movement.""" """Return whether a parameter describes a transient or relative movement."""
@@ -229,7 +289,7 @@ def _capability_schema_branch(capability: Mapping[str, object]) -> dict[str, obj
} }
def build_scene_output_schema(context: Mapping[str, object]) -> dict[str, object]: def build_scene_output_schema(context: Mapping[str, object], *, dynamic: bool = False) -> dict[str, object]:
"""Build a compact strict schema from the projected scene capability data.""" """Build a compact strict schema from the projected scene capability data."""
raw_capabilities = context.get("capabilities", []) raw_capabilities = context.get("capabilities", [])
@@ -267,6 +327,27 @@ def build_scene_output_schema(context: Mapping[str, object]) -> dict[str, object
"confidence": {"type": "number", "minimum": 0, "maximum": 1}, "confidence": {"type": "number", "minimum": 0, "maximum": 1},
}, },
} }
if dynamic:
phase_requests = schema["properties"]["requests"]
schema["properties"]["requests"] = {"type": "array", "maxItems": 0, "items": {"type": "string"}}
schema["required"].append("schedule")
schema["properties"]["schedule"] = {
"anyOf": [{"type": "null"}, {
"type": "object", "additionalProperties": False,
"required": ["repeat_count", "phases"],
"properties": {
"repeat_count": {"type": "integer", "minimum": 1, "maximum": 20},
"phases": {"type": "array", "minItems": 2, "maxItems": 8,
"items": {"type": "object", "additionalProperties": False,
"required": ["id", "name", "duration_ms", "requests"],
"properties": {
"id": {"type": "string"}, "name": {"type": "string"},
"duration_ms": {"type": "integer", "minimum": 1000, "maximum": 900000},
"requests": phase_requests,
}}},
},
}],
}
encoded_schema = json.dumps( encoded_schema = json.dumps(
schema, schema,
ensure_ascii=False, ensure_ascii=False,
@@ -280,9 +361,41 @@ def build_scene_output_schema(context: Mapping[str, object]) -> dict[str, object
def normalize_scene_output( def normalize_scene_output(
value: Mapping[str, Any], value: Mapping[str, Any],
context: Mapping[str, object], context: Mapping[str, object],
*, dynamic: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Expand the lean model response into the existing guarded plan contract.""" """Expand the lean model response into the existing guarded plan contract."""
if dynamic:
if not isinstance(value, Mapping) or set(value) != {"requests", "ambiguities", "confidence", "schedule"}:
raise SceneCompositionError("dynamic scene response must include a schedule")
if value["requests"]:
raise SceneCompositionError("dynamic scene must not mix static root requests")
base = normalize_scene_output({key: value[key] for key in ("requests", "ambiguities", "confidence")}, context)
raw_schedule = value["schedule"]
if raw_schedule is None:
return base
if not isinstance(raw_schedule, Mapping) or set(raw_schedule) != {"repeat_count", "phases"}:
raise SceneCompositionError("invalid dynamic scene schedule")
phases = []
if not isinstance(raw_schedule["phases"], list):
raise SceneCompositionError("dynamic phases must be an array")
for phase in raw_schedule["phases"]:
if not isinstance(phase, Mapping) or set(phase) != {"id", "name", "duration_ms", "requests"}:
raise SceneCompositionError("invalid dynamic phase")
normalized = normalize_scene_output({"requests": phase["requests"], "ambiguities": [], "confidence": value["confidence"]}, context)
operations = [{"domain": op["domain"], "capability": op["capability"],
"target_scope": op["target_scope"], "operation": "set",
"parameters": {p["name"]: p["value"] for p in op["parameters"]},
"reason": "经过时间轴编排的候选状态。"} for op in normalized["requests"]]
phases.append({"id": phase["id"], "name": phase["name"], "duration_ms": phase["duration_ms"], "operations": operations})
try:
schedule = ModeSchedule.from_dict({"schema": "cognitive-agent-mode-schedule/v1", "repeat_count": raw_schedule["repeat_count"], "phases": phases})
except (TypeError, ValueError) as exc:
raise SceneCompositionError(str(exc)) from exc
first = normalize_scene_output({"requests": raw_schedule["phases"][0]["requests"], "ambiguities": value["ambiguities"], "confidence": value["confidence"]}, context)
first["schedule"] = schedule.to_dict()
return first
if not isinstance(value, Mapping) or set(value) != { if not isinstance(value, Mapping) or set(value) != {
"requests", "requests",
"ambiguities", "ambiguities",
@@ -24,11 +24,13 @@ from cognitive_agent.application.assistant_plan_guard import (
SCENE_COMPOSABLE_CAPABILITIES, SCENE_COMPOSABLE_CAPABILITIES,
) )
from cognitive_agent.application.mode_profile import ModeOperation from cognitive_agent.application.mode_profile import ModeOperation
from cognitive_agent.application.mode_profile_intent import mode_creation_source_name
from cognitive_agent.application.mode_schedule import ModeSchedule
_TEMPLATE_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") _TEMPLATE_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
_SURROUNDING_PUNCTUATION = " \t\r\n,。,.!!??;;::'\"‘’“”《》<>【】[]()()" _SURROUNDING_PUNCTUATION = " \t\r\n,。,.!!??;;::'\"‘’“”《》<>【】[]()()"
_DOCUMENT_FIELDS = frozenset( _DOCUMENT_FIELDS = frozenset(
{"id", "name", "aliases", "description", "icon", "operations"} {"id", "name", "aliases", "description", "icon", "operations", "schedule"}
) )
@@ -66,6 +68,7 @@ class SceneTemplate:
operations: tuple[ModeOperation, ...] operations: tuple[ModeOperation, ...]
source: str source: str
revision: str revision: str
schedule: ModeSchedule | None = None
@classmethod @classmethod
def from_yaml(cls, path: Path) -> "SceneTemplate": def from_yaml(cls, path: Path) -> "SceneTemplate":
@@ -100,7 +103,13 @@ class SceneTemplate:
alias_keys.add(key) alias_keys.add(key)
aliases.append(alias) aliases.append(alias)
schedule = (ModeSchedule.from_dict(document["schedule"])
if document.get("schedule") is not None else None)
raw_operations = document.get("operations") raw_operations = document.get("operations")
if schedule is not None:
if raw_operations:
raise ValueError(f"{path} cannot mix static operations and a schedule")
raw_operations = list(schedule.phases[0].operations)
if not isinstance(raw_operations, list) or not raw_operations: if not isinstance(raw_operations, list) or not raw_operations:
raise ValueError(f"{path} must contain at least one operation") raise ValueError(f"{path} must contain at least one operation")
if len(raw_operations) > MAX_PLAN_REQUESTS: if len(raw_operations) > MAX_PLAN_REQUESTS:
@@ -155,6 +164,8 @@ class SceneTemplate:
), ),
), ),
} }
if schedule is not None:
semantic_document["schedule"] = schedule.to_dict()
canonical = json.dumps( canonical = json.dumps(
semantic_document, semantic_document,
ensure_ascii=False, ensure_ascii=False,
@@ -171,6 +182,7 @@ class SceneTemplate:
operations=tuple(operations), operations=tuple(operations),
source=str(path), source=str(path),
revision=revision, revision=revision,
schedule=schedule,
) )
def plan(self) -> dict[str, Any]: def plan(self) -> dict[str, Any]:
@@ -179,7 +191,7 @@ class SceneTemplate:
request = operation.to_request(self.name) request = operation.to_request(self.name)
request["duration_ms"] = None request["duration_ms"] = None
requests.append(request) requests.append(request)
return { plan = {
"detected_domains": list( "detected_domains": list(
dict.fromkeys(operation.domain for operation in self.operations) dict.fromkeys(operation.domain for operation in self.operations)
), ),
@@ -195,6 +207,9 @@ class SceneTemplate:
"template_revision": self.revision, "template_revision": self.revision,
}, },
} }
if self.schedule is not None:
plan["schedule"] = self.schedule.to_dict()
return plan
def public_dict(self, *, include_plan: bool = False) -> dict[str, Any]: def public_dict(self, *, include_plan: bool = False) -> dict[str, Any]:
result: dict[str, Any] = { result: dict[str, Any] = {
@@ -204,13 +219,16 @@ class SceneTemplate:
"description": self.description, "description": self.description,
"icon": self.icon, "icon": self.icon,
"domains": list(dict.fromkeys(item.domain for item in self.operations)), "domains": list(dict.fromkeys(item.domain for item in self.operations)),
"operation_count": len(self.operations), "operation_count": self.schedule.operation_count if self.schedule else len(self.operations),
"template_revision": self.revision, "template_revision": self.revision,
"origin": "builtin_scene_template", "origin": "builtin_scene_template",
"read_only": True, "read_only": True,
} }
if include_plan: if include_plan:
result["plan"] = self.plan() result["plan"] = self.plan()
if self.schedule is not None:
result["schedule"] = self.schedule.to_dict()
result["domains"] = sorted({op["domain"] for phase in self.schedule.phases for op in phase.operations})
return result return result
@@ -257,7 +275,11 @@ class SceneTemplateRegistry:
def resolve_exact_name(self, value: object) -> SceneTemplate | None: def resolve_exact_name(self, value: object) -> SceneTemplate | None:
key = normalize_scene_template_name(value) key = normalize_scene_template_name(value)
return self._by_name.get(key) if key else None exact = self._by_name.get(key) if key else None
if exact is not None:
return exact
subject = normalize_scene_template_name(mode_creation_source_name(value))
return self._by_name.get(subject) if subject and subject != key else None
def get_template(self, template_id: str) -> SceneTemplate: def get_template(self, template_id: str) -> SceneTemplate:
try: try:
@@ -0,0 +1,25 @@
"""Narrow detection of temporal scene needs; never authorizes execution."""
from __future__ import annotations
import re
_TEMPORAL = re.compile(
r"动态|循环|分阶段|随时间|交替|轮流|每隔|预热|预冷|"
r"(?:秒|分钟|分|小时)(?:钟)?(?:后|再)|持续.{0,8}(?:秒|分钟|小时)"
)
_STOP = re.compile(
r"^(?:请|帮我|立即|马上|先)?(?:停止|停下|结束|取消|关闭)"
r"(?:当前|正在执行的|正在运行的|这个|该)?(?:座椅|空调|功能)?(?:动态|循环|分阶段|时间轴|定时)"
r"(?:座椅|空调|功能)?(?:模式|编排|任务)?[。!!\s]*$"
)
def needs_mode_schedule(text: str) -> bool:
# HVAC recirculation is a stable device enum, not a timed repetition.
normalized = re.sub(r"内外循环|内循环|外循环|自动循环|新风循环|循环风", "空气循环状态", text)
normalized = normalized.replace("空气循环状态", "空气状态")
return bool(_TEMPORAL.search(normalized))
def is_schedule_stop(text: str) -> bool:
return bool(_STOP.fullmatch(text.strip()))
+267 -17
View File
@@ -47,17 +47,27 @@ from cognitive_agent.application.direct_can_control import ( # noqa: E402
build_direct_can_intent, build_direct_can_intent,
) )
from cognitive_agent.application.domain_runtime import ConfiguredDomainRuntime # noqa: E402 from cognitive_agent.application.domain_runtime import ConfiguredDomainRuntime # noqa: E402
from cognitive_agent.application.mode_creation_seat_policy import ( # noqa: E402
ModeCreationSeatPolicyError,
creation_seat_scopes,
project_seed_operations,
project_seed_plan,
project_seed_schedule,
)
from cognitive_agent.application.mode_profile import ( # noqa: E402 from cognitive_agent.application.mode_profile import ( # noqa: E402
ModeProfile, ModeProfile,
ModeProfileRegistry, ModeProfileRegistry,
) )
from cognitive_agent.application.mode_profile_intent import ( # noqa: E402 from cognitive_agent.application.mode_profile_intent import ( # noqa: E402
is_mode_profile_query, is_mode_profile_query,
mode_creation_source_name,
normalize_mode_profile_name, normalize_mode_profile_name,
parse_mode_profile_create, parse_mode_profile_create,
parse_mode_profile_delete, parse_mode_profile_delete,
parse_mode_profile_update, parse_mode_profile_update,
) )
from cognitive_agent.application.mode_schedule import ModeSchedule, ModeScheduleRunner # noqa: E402
from cognitive_agent.application.mode_schedule_service import ModeScheduleServiceMixin # noqa: E402
from cognitive_agent.application.model_plan_adapter import ( # noqa: E402 from cognitive_agent.application.model_plan_adapter import ( # noqa: E402
APPLY_PRESET_CAPABILITY, APPLY_PRESET_CAPABILITY,
DYNAMIC_MODE_STOP_CAPABILITY, DYNAMIC_MODE_STOP_CAPABILITY,
@@ -90,6 +100,10 @@ from cognitive_agent.application.native_can_runtime import ( # noqa: E402
NativeCanRuntime, NativeCanRuntime,
) )
from cognitive_agent.application.scene_templates import SceneTemplateRegistry # noqa: E402 from cognitive_agent.application.scene_templates import SceneTemplateRegistry # noqa: E402
from cognitive_agent.application.schedule_intent import ( # noqa: E402
is_schedule_stop,
needs_mode_schedule,
)
from cognitive_agent.application.seat_calibration import position_pct_to_angle # noqa: E402 from cognitive_agent.application.seat_calibration import position_pct_to_angle # noqa: E402
from cognitive_agent.application.semantic_ir import ( # noqa: E402 from cognitive_agent.application.semantic_ir import ( # noqa: E402
apply_language_safety_guards, apply_language_safety_guards,
@@ -331,8 +345,9 @@ DYNAMIC_MODE_WORDS = (
) )
class SeatDemoService: class SeatDemoService(ModeScheduleServiceMixin):
def __init__(self, repository_root: Path = REPOSITORY_ROOT): def __init__(self, repository_root: Path = REPOSITORY_ROOT):
self.mode_schedule_runner = ModeScheduleRunner()
self.root = repository_root self.root = repository_root
self.domain_root = repository_root / "domains" / "seat" self.domain_root = repository_root / "domains" / "seat"
self.contracts = ContractValidator(repository_root) self.contracts = ContractValidator(repository_root)
@@ -663,7 +678,7 @@ class SeatDemoService:
resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id
profile = self.mode_profiles.get_profile(mode_id, resolved_vehicle_id) profile = self.mode_profiles.get_profile(mode_id, resolved_vehicle_id)
if not profile.command_flow or not profile.command_flow.get("plans"): if profile.schedule is None and (not profile.command_flow or not profile.command_flow.get("plans")):
raise ValueError("该模式尚未生成可直接执行的控制指令流。") raise ValueError("该模式尚未生成可直接执行的控制指令流。")
result = self.process( result = self.process(
profile.instruction or f"进入{profile.name}", profile.instruction or f"进入{profile.name}",
@@ -678,6 +693,8 @@ class SeatDemoService:
control_transport=control_transport, control_transport=control_transport,
_direct_mode_profile_id=profile.id, _direct_mode_profile_id=profile.id,
) )
if profile.schedule is not None:
return result
public_profile = profile.public_dict() public_profile = profile.public_dict()
result["provider_used"] = "saved_mode_command_flow" result["provider_used"] = "saved_mode_command_flow"
result["utterance"] = profile.name result["utterance"] = profile.name
@@ -796,7 +813,7 @@ class SeatDemoService:
): ):
raise ValueError("功能模式草稿未通过安全校验,请重新生成。") raise ValueError("功能模式草稿未通过安全校验,请重新生成。")
return { trial = {
"request_id": clean_request_id, "request_id": clean_request_id,
"status": "succeeded", "status": "succeeded",
"execution_mode": "dry_run", "execution_mode": "dry_run",
@@ -809,6 +826,14 @@ class SeatDemoService:
"validation": deepcopy(validation), "validation": deepcopy(validation),
"trial_source": "server_validated_preview_artifact", "trial_source": "server_validated_preview_artifact",
} }
schedule = self._validated_schedule_artifact(source_result)
if schedule is not None:
trial["schedule"] = schedule.to_dict()
trial["schedule_execution"] = self.mode_schedule_runner.simulate(
schedule, lambda phase: {"status": "succeeded", "phase_id": phase.id,
"operations": deepcopy(list(phase.operations))},
)
return trial
def _persist_mode_profile_card( def _persist_mode_profile_card(
self, self,
@@ -1007,6 +1032,11 @@ class SeatDemoService:
# generation workflow. # generation workflow.
"execution_steps": [], "execution_steps": [],
} }
schedule = self._validated_schedule_artifact(source_result)
if profile is not None and profile.schedule is not None and schedule is None:
raise ValueError("动态模式更新必须保留完整时间轴,请按阶段重新生成预览。")
if schedule is not None:
operations = [deepcopy(op) for phase in schedule.phases for op in phase.operations]
saved = self.mode_profiles.save_mode( saved = self.mode_profiles.save_mode(
clean_name, clean_name,
operations, operations,
@@ -1022,6 +1052,7 @@ class SeatDemoService:
instruction=clean_instruction, instruction=clean_instruction,
command_flow=command_flow, command_flow=command_flow,
timeline=profile.timeline if profile is not None else None, timeline=profile.timeline if profile is not None else None,
schedule=schedule,
expected_revision=draft_existing_mode_revision or None, expected_revision=draft_existing_mode_revision or None,
create_only=not bool(clean_mode_id), create_only=not bool(clean_mode_id),
) )
@@ -1220,6 +1251,7 @@ class SeatDemoService:
self._vehicle_mqtt_transport.start() self._vehicle_mqtt_transport.start()
def close(self) -> None: def close(self) -> None:
self.mode_schedule_runner.close()
if self._vehicle_state_monitor is not None: if self._vehicle_state_monitor is not None:
self._vehicle_state_monitor.close() self._vehicle_state_monitor.close()
self._vehicle_mqtt_transport.close() self._vehicle_mqtt_transport.close()
@@ -2076,6 +2108,32 @@ class SeatDemoService:
or mode_profile_query or mode_profile_query
): ):
execution_mode = "dry_run" execution_mode = "dry_run"
schedule_requested = "模式" in utterance or _direct_mode_profile_id or is_schedule_stop(utterance)
schedule_vehicle_id = vehicle_id or (self.vehicle_profiles.default().id if schedule_requested else "")
if execution_mode not in {"dry_run", "simulated_hardware", "hardware"}:
raise ValueError(f"unsupported execution mode: {execution_mode}")
if is_schedule_stop(utterance):
return self._cancel_mode_schedule(schedule_vehicle_id, conversation_id, utterance, execution_mode)
schedule_profile = (self.mode_profiles.get_profile(_direct_mode_profile_id, schedule_vehicle_id)
if _direct_mode_profile_id else None)
if schedule_requested and schedule_profile is None and not (draft_update or mode_delete_name or mode_profile_query or parse_mode_profile_create(utterance)):
candidates = self.mode_profiles.match_text(utterance, vehicle_id=schedule_vehicle_id)
if len(candidates) == 1 and candidates[0].schedule is not None:
aliases = "|".join(re.escape(name) for name in (candidates[0].name, *candidates[0].aliases))
if re.fullmatch(
r"(?:(?:请|麻烦|帮我|帮忙|给我|我想|我要|想要)\s*){0,4}"
r"(?:(?:主驾|副驾|驾驶位|副驾驶|主驾驶)(?:座椅)?\s*)?"
r"(?:进入|启动|执行|开启|启用|运行|打开|调用|应用|使用|切换到)\s*"
rf"(?:{aliases})(?:吧|一下)?[。!!\s]*", utterance.strip(),
):
schedule_profile = candidates[0]
if schedule_profile is not None and schedule_profile.schedule is not None:
return self._run_mode_schedule(
schedule_profile, utterance=utterance, conversation_id=conversation_id,
execution_mode=execution_mode, approval_token=approval_token, vehicle_id=schedule_vehicle_id,
simulation_fault=simulation_fault, hardware_client_id=hardware_client_id,
hardware_access_bypass=hardware_access_bypass, control_transport=control_transport,
)
if execution_mode != "hardware": if execution_mode != "hardware":
return self._process_once( return self._process_once(
utterance, utterance,
@@ -2196,6 +2254,7 @@ class SeatDemoService:
allow_fuzzy_mode_profile_match: bool = True, allow_fuzzy_mode_profile_match: bool = True,
allow_scene_composition: bool = False, allow_scene_composition: bool = False,
scene_scope_utterance: str | None = None, scene_scope_utterance: str | None = None,
mode_creation_scopes: frozenset[str] | None = None,
mode_profile_draft_context: 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)
@@ -2454,6 +2513,8 @@ class SeatDemoService:
mode_requests = self.mode_profiles.requests_for_profiles( mode_requests = self.mode_profiles.requests_for_profiles(
[direct_mode_profile] [direct_mode_profile]
) )
if mode_creation_scopes is not None:
mode_requests = project_seed_operations(mode_requests, mode_creation_scopes)
detected_domains = list( detected_domains = list(
dict.fromkeys( dict.fromkeys(
str(item.get("domain") or "") str(item.get("domain") or "")
@@ -3687,6 +3748,16 @@ class SeatDemoService:
) -> dict[str, Any]: ) -> dict[str, Any]:
if not matched_modes: if not matched_modes:
return parsed return parsed
if any(mode.schedule is not None for mode in matched_modes):
rejected = deepcopy(parsed)
for key in ("actions", "domain_actions", "native_can_actions", "preset_actions",
"preset_events", "deferred_requests", "model_requests"):
rejected[key] = []
rejected["ambiguities"] = [{
"field": "mode_profile",
"question": "动态模式必须通过时间轴执行,请独立输入“进入模式名称”;不会将各阶段压平执行。",
}]
return rejected
if self._contains_seat_preset_request(parsed): if self._contains_seat_preset_request(parsed):
return parsed return parsed
if len(matched_modes) > 1: if len(matched_modes) > 1:
@@ -3767,6 +3838,19 @@ class SeatDemoService:
return parsed return parsed
mode_requests = self.mode_profiles.requests_for_profiles(matched_modes) mode_requests = self.mode_profiles.requests_for_profiles(matched_modes)
selected_seat_scope = self._explicit_mode_seat_selection(
matched_modes[0], utterance,
)
if selected_seat_scope is not None:
# A cabin profile can carry independent comfort operations for both
# front seats. An explicit single-seat invocation selects only that
# seat; it must not authorize its sibling or mutate the stored mode.
mode_requests = [
request for request in mode_requests
if request["domain"] != "seat"
or self._normalized_mode_seat_scope(request["target_scope"])
in selected_seat_scope
]
detected_domains = [] detected_domains = []
for request in mode_requests: for request in mode_requests:
if request["domain"] not in detected_domains: if request["domain"] not in detected_domains:
@@ -3787,6 +3871,24 @@ class SeatDemoService:
context, context,
vehicle_profile, vehicle_profile,
) )
# Native-only comfort capabilities are represented as deferred items by
# the generic adapter. A verified stored mode must materialize them too;
# otherwise a successful named-mode run silently drops its driver seat.
mode_native_actions = []
for item in mode_parsed.get("deferred_requests", []):
if item.get("execution_backend") != "native_can":
continue
action = {
"domain": item["domain"],
"capability": item["capability"],
"target": item["target_scope"],
"parameters": deepcopy(item.get("parameters", {})),
"operation": item.get("desired_change", "set"),
"reason": item.get("reason", ""),
"execution_backend": "native_can",
}
if self.native_can_runtime.supports(action):
mode_native_actions.append(action)
parsed_has_explicit_request = bool( parsed_has_explicit_request = bool(
parsed.get("actions") parsed.get("actions")
or parsed.get("domain_actions") or parsed.get("domain_actions")
@@ -3803,6 +3905,10 @@ class SeatDemoService:
mode_parsed.get("domain_actions", []), mode_parsed.get("domain_actions", []),
parsed.get("domain_actions", []), parsed.get("domain_actions", []),
) )
merged["native_can_actions"] = self._merge_control_actions(
mode_native_actions,
parsed.get("native_can_actions", []),
)
for key in ( for key in (
"preset_actions", "preset_actions",
"preset_events", "preset_events",
@@ -3834,13 +3940,35 @@ class SeatDemoService:
) )
return merged return merged
@staticmethod @classmethod
def _explicit_mode_target_conflict( def _explicit_mode_target_conflict(
cls,
profile: ModeProfile, profile: ModeProfile,
utterance: str, utterance: str,
) -> tuple[str, tuple[str, ...]] | None: ) -> tuple[str, tuple[str, ...]] | None:
"""Reject a named mode when its persisted seat scope contradicts text.""" """Reject a named mode when its persisted seat scope contradicts text."""
requested, normalized_scopes = cls._explicit_mode_seat_scopes(profile, utterance)
if requested is None or requested == normalized_scopes:
return None
if cls._explicit_mode_seat_selection(profile, utterance) is not None:
return None
return ",".join(sorted(requested)), tuple(sorted(normalized_scopes))
@staticmethod
def _normalized_mode_seat_scope(scope: str) -> str:
return (
"driver" if scope == "front_left" else
"passenger" if scope == "front_right" else
"rear" if scope in {"rear", "rear_left", "rear_right"} else scope
)
@classmethod
def _explicit_mode_seat_scopes(
cls,
profile: ModeProfile,
utterance: str,
) -> tuple[frozenset[str] | None, set[str]]:
aliases = (profile.id, profile.name, *profile.aliases) aliases = (profile.id, profile.name, *profile.aliases)
mode_clause = next( mode_clause = next(
( (
@@ -3856,23 +3984,34 @@ class SeatDemoService:
utterance, utterance,
) )
requested = resolve_explicit_seat_scopes( requested = resolve_explicit_seat_scopes(
mode_clause, mode_clause.replace("主副驾", "主驾和副驾"),
assume_seat_context=True, assume_seat_context=True,
) )
if requested is None:
return None
normalized_scopes = { normalized_scopes = {
"driver" if operation.target_scope == "front_left" else cls._normalized_mode_seat_scope(operation.target_scope)
"passenger" if operation.target_scope == "front_right" else
"rear" if operation.target_scope in {"rear", "rear_left", "rear_right"} else
operation.target_scope
for operation in profile.operations for operation in profile.operations
if operation.domain == "seat" if operation.domain == "seat"
} }
if requested == normalized_scopes: return requested, normalized_scopes
@classmethod
def _explicit_mode_seat_selection(
cls,
profile: ModeProfile,
utterance: str,
) -> frozenset[str] | None:
"""Scope a dual-seat mode invocation, never mode creation or editing."""
requested, supported = cls._explicit_mode_seat_scopes(profile, utterance)
if (
requested is None
or len(requested) != 1
or supported != {"driver", "passenger"}
or not requested.issubset(supported)
or cls.is_mode_profile_configuration_request(utterance)
or not re.search(r"进入|开启|启动|执行|启用|运行|切换|打开|调用|应用|使用", utterance)
):
return None return None
return ",".join(sorted(requested)), tuple(sorted(normalized_scopes)) return requested
@staticmethod @staticmethod
def _apply_implicit_mode_target_policy( def _apply_implicit_mode_target_policy(
@@ -4205,7 +4344,9 @@ class SeatDemoService:
for issue in validation for issue in validation
): ):
raise ValueError("待保存功能模式草稿没有通过完整安全校验。") raise ValueError("待保存功能模式草稿没有通过完整安全校验。")
operations = self._preview_mode_operations(source_result) schedule = self._validated_schedule_artifact(source_result)
operations = ([deepcopy(op) for phase in schedule.phases for op in phase.operations]
if schedule is not None else self._preview_mode_operations(source_result))
operation_count = draft.get("operation_count") operation_count = draft.get("operation_count")
if ( if (
not isinstance(operation_count, int) not isinstance(operation_count, int)
@@ -4335,6 +4476,16 @@ class SeatDemoService:
vehicle_id=vehicle_profile.id, vehicle_id=vehicle_profile.id,
) )
) )
if source_draft.get("schedule") is not None:
from cognitive_agent.application.mode_schedule_edit import revise_schedule
return revise_schedule(
self, ModeSchedule.from_dict(source_draft["schedule"]),
change_text=change_text, utterance=utterance, name=mode_name,
conversation_id=conversation_id, vehicle_profile=vehicle_profile,
context=context, control_transport=control_transport,
source_draft=source_draft,
)
base_operations = self._absolute_mode_operations(source_operations) base_operations = self._absolute_mode_operations(source_operations)
if not change_text: if not change_text:
raise ValueError("请说明要调整的设备和明确目标值。") raise ValueError("请说明要调整的设备和明确目标值。")
@@ -4684,6 +4835,14 @@ class SeatDemoService:
if profile is None: if profile is None:
self._persist(result) self._persist(result)
return result return result
if profile.schedule is not None:
from cognitive_agent.application.mode_schedule_edit import update_saved_schedule
return update_saved_schedule(
self, profile, change_text=change_text, utterance=utterance,
conversation_id=conversation_id, vehicle_profile=vehicle_profile,
context=context, result=result,
)
if not change_text: if not change_text:
result["status"] = "needs_confirmation" result["status"] = "needs_confirmation"
result["validation"] = [ result["validation"] = [
@@ -5351,6 +5510,25 @@ class SeatDemoService:
result["scene_config"] = None result["scene_config"] = None
result["execution_report"] = None result["execution_report"] = None
def _mode_creation_seat_failure(
self, utterance: str, conversation_id: str, vehicle: VehicleProfile, reason: str,
) -> dict[str, Any]:
result = {
"request_id": f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex}",
"utterance": utterance, "conversation_id": conversation_id,
"vehicle": vehicle.public_dict(), "status": "validation_failed",
"execution_mode": "dry_run", "hardware_execution_allowed": False,
"orchestration_route": "mode_profile_create_preview", "trace_events": [],
"validation": [{"severity": "error", "code": "MODE_SEAT_SCOPE_UNAVAILABLE",
"path": "mode_profile_draft.seat_scope", "message": reason}],
"mode_creation_failure": {"code": "MODE_SEAT_SCOPE_UNAVAILABLE", "reason": reason,
"next_step": "请选择该模式实际支持的座位,或改用当前座位支持的舒适功能"},
"decision_summary": [reason, "没有保存模式或下发控制。"],
}
self._scrub_failed_scene_preview(result)
self._persist(result)
return result
def _process_mode_profile_create( def _process_mode_profile_create(
self, self,
utterance: str, utterance: str,
@@ -5385,6 +5563,14 @@ class SeatDemoService:
): ):
raise ValueError("功能模式名称与创建请求不一致。") raise ValueError("功能模式名称与创建请求不一致。")
requested_instruction = create_parts[1] if create_parts is not None else "" requested_instruction = create_parts[1] if create_parts is not None else ""
selected_seats = creation_seat_scopes(utterance) if source_draft is None else None
if selected_seats is not None and "unsupported" in selected_seats and re.search(
r"座椅|座位|靠背|椅背|腿托|脚托|坐垫|坐盆|按摩|零重力", utterance
):
return self._mode_creation_seat_failure(
utterance, conversation_id, vehicle_profile,
"当前模式只支持明确的主驾或副驾座椅,不会将未支持的座位替换为其他座位。",
)
raw_plan_metadata = ( raw_plan_metadata = (
assistant_plan.get("metadata") assistant_plan.get("metadata")
if isinstance(assistant_plan, dict) if isinstance(assistant_plan, dict)
@@ -5411,6 +5597,7 @@ class SeatDemoService:
or requested_instruction or requested_instruction
or assistant_plan.get("requests") or assistant_plan.get("requests")
!= candidate_template.plan().get("requests") != candidate_template.plan().get("requests")
or assistant_plan.get("schedule") != candidate_template.plan().get("schedule")
): ):
raise ValueError("内置常用模式模板已更新,请重新提交。") raise ValueError("内置常用模式模板已更新,请重新提交。")
scene_template = candidate_template scene_template = candidate_template
@@ -5441,6 +5628,10 @@ class SeatDemoService:
if not requested_instruction and source_draft is None if not requested_instruction and source_draft is None
else None else None
) )
if source_profile is None and source_draft is None and not requested_instruction and scene_template is None:
source_name = mode_creation_source_name(mode_name)
if source_name != mode_name:
source_profile = self.mode_profiles.resolve_exact_name(source_name, vehicle_id=vehicle_profile.id)
draft_mode_name = ( draft_mode_name = (
str(source_draft.get("name") or mode_name).strip() str(source_draft.get("name") or mode_name).strip()
if isinstance(source_draft, dict) if isinstance(source_draft, dict)
@@ -5456,7 +5647,7 @@ class SeatDemoService:
else "registered_mode_profile" 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 or needs_mode_schedule(utterance))
else "explicit_scene_requirements" else "explicit_scene_requirements"
) )
# A name-only request may already describe a repository-backed scene. # A name-only request may already describe a repository-backed scene.
@@ -5465,7 +5656,63 @@ class SeatDemoService:
# being parsed as a second control clause on the assistant's local fast # 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"进入{draft_mode_name}" source_utterance = requested_instruction or f"进入{draft_mode_name}"
if requested_instruction and selected_seats is not None and len(selected_seats) == 1:
# Qualify only explicit seat-device clauses, never an HVAC/window
# zone or unrelated control. General one-shot controls keep their
# existing target policy; this is scoped to new mode construction.
label = "主驾" if selected_seats == {"driver"} else "副驾"
instruction_clauses = self._split_intent_clauses(requested_instruction)
qualified_clauses = [
f"{label}{clause}" if (
re.search(r"座椅|座位|靠背|椅背|腿托|脚托|坐垫|坐盆|按摩", clause)
and resolve_explicit_seat_scopes(clause, assume_seat_context=True) is None
) else clause
for clause in instruction_clauses
]
# Preserve the user's punctuation and wording unless a seat really
# needs qualification; explicit instructions need no rewriting.
if qualified_clauses != instruction_clauses:
source_utterance = ",".join(qualified_clauses)
raw_schedule = (assistant_plan.get("schedule") if isinstance(assistant_plan, dict) else None)
if source_profile is not None and source_profile.schedule is not None:
raw_schedule = source_profile.schedule.to_dict()
if raw_schedule is not None:
if isinstance(assistant_plan, dict) and assistant_plan.get("ambiguities"):
raise ValueError("动态需求仍有未解决的问题,不能生成部分成功的时间轴,请补充或调整需求。")
if source_profile is None and source_draft is None and scene_template is None and not needs_mode_schedule(utterance):
raise ValueError("用户未请求动态或分阶段模式,不能擅自生成定时任务。")
schedule = ModeSchedule.from_dict(raw_schedule)
if selected_seats is not None and (source_profile is not None or scene_template is not None):
try: try:
schedule = project_seed_schedule(schedule, selected_seats)
except ModeCreationSeatPolicyError as exc:
return self._mode_creation_seat_failure(utterance, conversation_id, vehicle_profile, str(exc))
return self._preview_mode_schedule(
schedule, name=draft_mode_name, utterance=utterance,
conversation_id=conversation_id, vehicle_profile=vehicle_profile,
control_transport=control_transport, generation_source=generation_source,
source_profile=existing_profile, source_template=scene_template, source_draft=source_draft,
scene_scope_utterance="" if source_draft is not None else utterance,
)
if needs_mode_schedule(utterance) and scene_template is None and not (source_profile is not None and source_profile.timeline):
result = {
"request_id": f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex}",
"utterance": utterance, "conversation_id": conversation_id,
"vehicle": vehicle_profile.public_dict(), "validation": [], "trace_events": [],
"status": "validation_failed", "execution_mode": "dry_run", "hardware_execution_allowed": False,
"orchestration_route": "mode_profile_create_preview",
}
result["mode_creation_failure"] = {
"code": "MODE_SCHEDULE_REQUIRED",
"reason": "该需求包含时间变化,但没有生成完整的分阶段时间轴",
"next_step": "请重试,或补充每个阶段的设备目标和持续时间;未保存或执行静态替代动作",
}
self._scrub_failed_scene_preview(result)
self._persist(result)
return result
try:
if scene_template is not None and selected_seats is not None:
assistant_plan = project_seed_plan(assistant_plan, selected_seats)
source_result = self._process_once( source_result = self._process_once(
source_utterance, source_utterance,
provider, provider,
@@ -5478,6 +5725,7 @@ class SeatDemoService:
direct_mode_profile_id=( direct_mode_profile_id=(
source_profile.id if source_profile is not None else None source_profile.id if source_profile is not None else None
), ),
mode_creation_scopes=selected_seats,
assistant_plan=assistant_plan, assistant_plan=assistant_plan,
# ``source_utterance`` is synthesized for a name-only, unknown # ``source_utterance`` is synthesized for a name-only, unknown
# scene. It must not fall back to substring matching (for example # scene. It must not fall back to substring matching (for example
@@ -5493,8 +5741,10 @@ class SeatDemoService:
# already passed the ordinary text/target authorization path. # already passed the ordinary text/target authorization path.
# Its old display name cannot veto an explicitly requested new # Its old display name cannot veto an explicitly requested new
# seat. This override is internal, never accepted from payloads. # seat. This override is internal, never accepted from payloads.
scene_scope_utterance="" if source_draft is not None else None, scene_scope_utterance="" if source_draft is not None else utterance,
) )
except ModeCreationSeatPolicyError as exc:
return self._mode_creation_seat_failure(utterance, conversation_id, vehicle_profile, str(exc))
except NativeCanCompileError: except NativeCanCompileError:
# Repository modes and model-composed scenes share the same native # Repository modes and model-composed scenes share the same native
# compiler. A stale mode parameter must become a bounded failed # compiler. A stale mode parameter must become a bounded failed
+10 -7
View File
@@ -8,7 +8,6 @@ from collections.abc import Mapping
from copy import deepcopy from copy import deepcopy
from typing import Any from typing import Any
CLAUSE_SEPARATOR = re.compile(r"[,,;;。!?!?\n]+") CLAUSE_SEPARATOR = re.compile(r"[,,;;。!?!?\n]+")
NEGATED_OPERATION = re.compile( NEGATED_OPERATION = re.compile(
r"(?:不要|别|不想|无需|不必|禁止)" r"(?:不要|别|不想|无需|不必|禁止)"
@@ -414,13 +413,17 @@ def apply_language_safety_guards(
] ]
result["confidence"] = min(float(result.get("confidence", 0)), 0.8) result["confidence"] = min(float(result.get("confidence", 0)), 0.8)
explicit_seat_scopes = ( if scene_composition:
resolve_scene_seat_scopes( # Reuse exactly the selection already applied to the model schema and
# trusted seed. A second, older scope parser must not reject aliases
# such as 前排两座 or widen 副驾驶席 to both seats after validation.
from cognitive_agent.application.mode_creation_seat_policy import creation_seat_scopes
explicit_seat_scopes = (None if scene_scope_utterance == "" else creation_seat_scopes(
utterance if scene_scope_utterance is None else scene_scope_utterance utterance if scene_scope_utterance is None else scene_scope_utterance
) ))
if scene_composition else:
else resolve_explicit_seat_scopes(utterance) explicit_seat_scopes = resolve_explicit_seat_scopes(utterance)
)
if scene_composition and not any( if scene_composition and not any(
isinstance(item, dict) isinstance(item, dict)
and ( and (
+9
View File
@@ -47,6 +47,7 @@ from cognitive_agent.application.mode_profile_intent import ( # noqa: E402
normalize_mode_profile_name, normalize_mode_profile_name,
parse_mode_profile_update, parse_mode_profile_update,
) )
from cognitive_agent.application.mode_schedule_service import public_schedule_status # noqa: E402
from cognitive_agent.application.seat_service import SeatDemoService # noqa: E402 from cognitive_agent.application.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,
@@ -304,6 +305,14 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if path == "/api/runtime": if path == "/api/runtime":
self._json(self.service.runtime_info()) self._json(self.service.runtime_info())
return return
if path == "/api/modes/schedule/status":
query = parse_qs(parsed_url.query)
try:
vehicle = self.service.vehicle_profiles.get(str(query.get("vehicle_id", [""])[0]) or None)
self._json(public_schedule_status(self.service.mode_schedule_runner.status(vehicle.id)))
except ValueError as exc:
self._json({"error": "invalid_request", "message": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if path == "/api/vehicle-display-sync": if path == "/api/vehicle-display-sync":
self._json(self.vehicle_display_sync.snapshot(self._extension_health())) self._json(self.vehicle_display_sync.snapshot(self._extension_health()))
return return
+256
View File
@@ -0,0 +1,256 @@
"use strict";
const assert = require("node:assert/strict");
const path = require("node:path");
const { chromium } = require("playwright");
const [baseURL, evidenceDir] = process.argv.slice(2);
async function main() {
const browser = await chromium.launch({
headless: true,
...(process.env.N7WEB_BROWSER_CHANNEL ? { channel: process.env.N7WEB_BROWSER_CHANNEL }
: process.platform === "win32" ? { channel: "msedge" } : {}),
});
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 }, serviceWorkers: "block" });
const page = await context.newPage();
const errors = [];
const posted = [];
const responses = [];
let mockedExecution = null;
let statusReads = 0;
page.on("pageerror", (error) => errors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") errors.push(`${message.text()} ${message.location().url || ""}`);
});
await context.route("**/api/**", async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (["/api/hardware/seat-state", "/api/hardware/vehicle-state"].includes(pathname)) {
await route.fulfill({ json: { status: "unavailable", hardware_execution_allowed: false } });
return;
}
if (request.method() !== "GET") posted.push({ pathname, payload: request.postDataJSON() });
if (mockedExecution && pathname === "/api/assistant/stream") {
await route.fulfill({ contentType: "application/x-ndjson", body: [
{ type: "action.completed", success: true, payload: mockedExecution },
{ type: "answer.completed", answer: "动态时间轴已启动,后续阶段会按计划执行。" },
].map((event) => JSON.stringify(event)).join("\n") + "\n" });
return;
}
if (mockedExecution && pathname === "/api/modes/schedule/status") {
statusReads += 1;
assert.equal(new URL(request.url()).searchParams.get("vehicle_id"), "denza_n7");
await route.fulfill({ json: {
...mockedExecution.schedule_execution, vehicle_id: "denza_n7",
phase_index: 1, repeat_index: 0, completed: false,
status: statusReads === 1 ? "running" : "failed",
error: statusReads === 1 ? null : "QA阶段模拟失败",
} });
return;
}
if (["/api/assistant/stream", "/api/mode-profile-drafts/trial", "/api/mode-profiles"].includes(pathname)) {
// Chromium can discard completed streaming response bodies from CDP.
// Fetch the real isolated endpoint and retain its exact response bytes.
const response = await route.fetch();
const text = await response.text();
responses.push({ pathname, text, status: response.status() });
await route.fulfill({ response, body: text });
return;
}
await route.continue();
});
const waitIdle = () => page.waitForFunction(() => !window.CognitiveAgentAssistant.isPending());
const responseFor = (pathname) => page.waitForResponse((response) => new URL(response.url()).pathname === pathname);
try {
await page.goto(baseURL, { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => document.body.classList.contains("assistant-chat-enabled")
&& document.querySelectorAll(".mode-chips button").length > 3);
assert.equal(await page.title(), "511工程-智能座舱");
assert.equal(new URL(page.url()).origin, baseURL);
assert.match(await page.locator("body").innerText(), /整车孪生界面/);
if (!await page.locator('[data-execution-mode="dry_run"]').isVisible()) {
await page.locator('#v2SidebarToggle').click();
}
await page.locator('[data-execution-mode="dry_run"]').click();
assert.equal(await page.evaluate(() => window.CognitiveAgentConsole.getExecutionMode()), "dry_run");
// Exercise the same real assistant endpoint and rendered cards for both
// implicit passenger selection and an explicit driver request. These
// previews stay unsaved, so the timeline save/trial tests remain isolated.
for (const [index, testCase] of [
{ text: "构建一个阅读模式", seat: "passenger", label: "副驾", excluded: "主驾" },
{ text: "构建一个主驾阅读模式", seat: "driver", label: "主驾", excluded: "副驾" },
{ text: "构建一个主驾座椅动态模式", seat: "driver", label: "主驾", excluded: "副驾" },
].entries()) {
const responsePromise = responseFor("/api/assistant/stream");
await page.locator("#utterance").fill(testCase.text);
await page.locator("#utterance").press("Enter");
const response = await responsePromise;
assert.equal(response.status(), 200);
const events = responses.filter((item) => item.pathname === "/api/assistant/stream").at(-1)
.text.trim().split("\n").map((line) => JSON.parse(line));
await waitIdle();
const payload = events.find((event) => event.payload?.mode_profile_draft)?.payload;
assert.ok(payload, `Missing selected-seat draft for ${testCase.text}: ${JSON.stringify(events)}`);
const draft = payload.mode_profile_draft;
const actions = draft.schedule ? draft.schedule.phases.flatMap((phase) => phase.operations) : payload.actions;
const seats = actions.filter((action) => action.capability?.startsWith("seat."))
.map((action) => action.target_scope || action.target?.instance || action.target?.zone);
assert.ok(seats.length, `Missing selected-seat actions for ${testCase.text}`);
assert.deepEqual([...new Set(seats)], [testCase.seat]);
const card = page.locator(`[data-assistant-mode-draft="${draft.source_request_id}"]`);
await card.waitFor({ state: "visible" });
const actionText = await card.locator(".assistant-mode-draft-row").allTextContents();
assert.ok(actionText.some((text) => text.includes(testCase.label)), actionText.join("\n"));
assert.ok(actionText.every((text) => !text.includes(testCase.excluded)), actionText.join("\n"));
await card.scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(evidenceDir, `seat-scope-preview-${index + 1}.png`) });
}
const savedModes = [];
for (const [index, text] of ["构建一个座椅的动态模式", "构建一个上车预热的功能模式"].entries()) {
const responsePromise = responseFor("/api/assistant/stream");
await page.locator("#utterance").fill(text);
await page.locator("#utterance").press("Enter");
const response = await responsePromise;
assert.equal(response.status(), 200);
const events = responses.filter((item) => item.pathname === "/api/assistant/stream").at(-1).text.trim().split("\n").map((line) => JSON.parse(line));
await waitIdle();
const draftEvent = events.find((event) => event.payload?.mode_profile_draft);
assert.ok(draftEvent, `Missing schedule draft for ${text}: ${JSON.stringify(events)}`);
const draft = draftEvent.payload.mode_profile_draft;
assert.equal(draft.schedule?.schema, "cognitive-agent-mode-schedule/v1");
assert.ok(draft.schedule.phases.length >= 2);
const card = page.locator(`[data-assistant-mode-draft="${draft.source_request_id}"]`);
assert.equal(await card.locator(".assistant-mode-schedule-phase").count(), draft.schedule.phases.length);
assert.equal(await card.locator(".assistant-mode-draft-row").count(), draft.operation_count);
assert.match(await card.innerText(), /重复 \d+ 次 · 总时长/);
assert.match(await card.innerText(), /停止动态模式/);
if (index === 0) {
assert.match(await card.innerText(), /副驾/);
assert.doesNotMatch(await card.innerText(), /主驾/);
} else {
const temperatures = draft.schedule.phases.flatMap((phase) => phase.operations)
.filter((operation) => operation.capability === "climate.set_temperature")
.map((operation) => operation.parameters.temperature_c);
assert.ok(new Set(temperatures).size >= 2, "preheat must change temperature over time");
for (const value of new Set(temperatures)) assert.ok((await card.innerText()).includes(`${value}℃`));
}
await card.scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(evidenceDir, `schedule-preview-${index + 1}.png`) });
const trialPromise = responseFor("/api/mode-profile-drafts/trial");
await card.locator(".assistant-mode-draft-trial").click();
const trialResponse = await trialPromise;
const trial = JSON.parse(responses.filter((item) => item.pathname === "/api/mode-profile-drafts/trial").at(-1).text);
assert.equal(trialResponse.status(), 200, JSON.stringify(trial));
assert.equal(trial.status, "succeeded", JSON.stringify(trial));
assert.equal(trial.schedule_execution?.status, "completed");
await page.waitForFunction((requestId) => {
const status = document.querySelector(`[data-assistant-mode-draft="${requestId}"] .assistant-mode-draft-status`);
return status?.classList.contains("success") || status?.classList.contains("failed");
}, draft.source_request_id);
assert.match(await card.locator(".assistant-mode-draft-status").innerText(), /试运行通过/);
const savePromise = responseFor("/api/mode-profiles");
await card.locator(".assistant-mode-draft-save").click();
const saveResponse = await savePromise;
const saved = JSON.parse(responses.filter((item) => item.pathname === "/api/mode-profiles").at(-1).text);
assert.ok([200, 201].includes(saveResponse.status()), JSON.stringify(saved));
assert.ok(saved.mode_profile?.id);
savedModes.push(saved.mode_profile);
await page.locator(".assistant-mode-created").filter({ hasText: saved.mode_profile.name }).waitFor({ state: "visible" });
const button = page.locator(`.mode-chips button[data-mode-profile-id="${saved.mode_profile.id}"]`);
await button.waitFor({ state: "visible" });
// Saving reveals the card with smooth scrolling. A tooltip correctly
// closes while its rail is still moving, so wait for that motion to end.
await page.evaluate(() => new Promise((resolve) => {
const rail = document.querySelector('.mode-chips');
let previous = rail.scrollLeft;
let stable = 0;
const sample = () => {
const current = rail.scrollLeft;
stable = Math.abs(current - previous) < 0.1 ? stable + 1 : 0;
previous = current;
if (stable >= 12) resolve();
else requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
}));
await page.mouse.move(1, 1);
const buttonBox = await button.boundingBox();
const railBox = await page.locator('.mode-chips').boundingBox();
const visibleLeft = Math.max(buttonBox.x, railBox.x);
const visibleRight = Math.min(buttonBox.x + buttonBox.width, railBox.x + railBox.width);
assert.ok(visibleRight > visibleLeft, 'saved card is visible in the mode rail');
// A native mouse move does not request another smooth scroll, unlike
// locator.hover() which scrolls the target to the center first.
await page.mouse.move((visibleLeft + visibleRight) / 2, buttonBox.y + buttonBox.height / 2);
const tooltip = page.locator("#modeChipActionPopover");
await tooltip.waitFor({ state: "visible" });
assert.match(await tooltip.innerText(), /重复 \d+ 次 · 总时长/);
assert.equal(await tooltip.locator(".mode-schedule-popover-phase").count(), draft.operation_count);
for (const phase of draft.schedule.phases) assert.ok((await tooltip.innerText()).includes(phase.name));
await tooltip.screenshot({ path: path.join(evidenceDir, `schedule-tooltip-${index + 1}.png`), timeout: 4000 });
await page.mouse.move(1, 1);
}
await page.locator("#modeGalleryToggle").click();
for (const mode of savedModes) {
const shell = page.locator(`#modeGalleryGroups .mode-gallery-card-shell`).filter({ has: page.locator(`button[data-mode-profile-id="${mode.id}"]`) });
const details = shell.locator(".mode-gallery-schedule-details");
await details.locator("summary").click();
assert.match(await details.innerText(), /开始 · 持续/);
assert.match(await details.innerText(), /不自动回滚/);
}
await page.locator("#modeGalleryGroups").evaluate((element) => { element.scrollTop = 0; });
await page.screenshot({ path: path.join(evidenceDir, "schedule-management-desktop.png") });
await page.locator("#modeGalleryClose").click();
await page.setViewportSize({ width: 720, height: 1000 });
if (await page.locator('#v2SidebarToggle').getAttribute('aria-expanded') === 'true') {
await page.locator('#v2SidebarToggle').click();
}
await page.locator(".assistant-mode-draft-scheduled").last().scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(evidenceDir, "schedule-preview-mobile.png") });
assert.equal(await page.locator("body").evaluate((body) => body.scrollWidth > window.innerWidth + 1), false);
// The backend sequence above is real and strictly dry-run. Separately
// verify the asynchronous UI contract with a synthetic running/failed
// response; it must never launch an actual timed hardware execution.
await page.setViewportSize({ width: 1600, height: 1000 });
const mode = savedModes[1];
mockedExecution = {
request_id: "browser-running-qa", status: "running", execution_mode: "dry_run",
orchestration_route: "mode_schedule_execute", vehicle_id: "denza_n7", mode_profile: mode,
schedule_execution: { status: "running", run_id: "qa-run", session_id: "qa-session" },
};
const messagesBefore = await page.locator('#assistantMessages [data-message-role]').count();
await page.locator(`.mode-chips button[data-mode-profile-id="${mode.id}"]`).click();
await waitIdle();
await page.waitForFunction((id) => document.querySelector(`.mode-chips button[data-mode-profile-id="${id}"]`)?.dataset.modeState === "started", mode.id);
const actionStatus = page.locator('.assistant-action-status').last();
assert.match(await actionStatus.innerText(), /已启动动态时间轴/);
await page.waitForFunction(() => Array.from(document.querySelectorAll('.assistant-action-status')).at(-1)?.textContent.includes('阶段 2'));
await page.waitForFunction(() => Array.from(document.querySelectorAll('.assistant-action-status')).at(-1)?.textContent.includes('QA阶段模拟失败'));
assert.equal(await actionStatus.getAttribute('class'), 'assistant-action-status failed');
assert.equal(statusReads, 2);
assert.equal(await page.locator('#assistantMessages [data-message-role]').count(), messagesBefore + 2);
await page.screenshot({ path: path.join(evidenceDir, "schedule-background-failure.png") });
assert.deepEqual(errors, []);
assert.ok(posted.filter((request) => request.pathname === "/api/assistant/stream").every((request) => request.payload.execution_mode === "dry_run"));
assert.equal(posted.filter((request) => request.pathname === "/api/mode-profile-drafts/trial").length, 2);
assert.equal(posted.filter((request) => request.pathname === "/api/mode-profiles").length, 2);
console.log(`PASS ${baseURL}: default passenger and explicit driver static/dynamic previews -> both exact user phrases -> phased preview -> virtual trial -> save -> top tooltip -> management; desktop 1600x1000 and 720x1000; no JS errors; no hardware requests forwarded`);
console.log(`Evidence: ${evidenceDir}`);
} catch (error) {
await page.screenshot({ path: path.join(evidenceDir, "schedule-browser-failure.png") });
console.error(`Page text: ${(await page.locator("body").innerText()).slice(-5000)}`);
throw error;
} finally {
await context.close();
await browser.close();
}
}
main().catch((error) => { console.error(error); process.exitCode = 1; });
+109
View File
@@ -0,0 +1,109 @@
"""Full browser -> deterministic assistant -> schedule trial/save, without hardware."""
from __future__ import annotations
import os
import shutil
import subprocess
import threading
from http import HTTPStatus
from http.server import ThreadingHTTPServer
from pathlib import Path
from unittest.mock import MagicMock, patch
from urllib.parse import urlparse
import pytest
from cognitive_agent.application.codex_assistant import CodexAssistantService
from cognitive_agent.application.seat_service import SeatDemoService
from cognitive_agent.auth import AuthConfig, AuthRuntime
from cognitive_agent.server import REPOSITORY_ROOT, SeatDemoHandler
def test_mode_schedule_real_browser_preview_trial_and_save(tmp_path: Path) -> None:
node = shutil.which("node")
if not node:
runtime = Path.home() / ".cache" / "codex-runtimes" / "codex-primary-runtime" / "dependencies" / "node" / "bin"
node = next((str(path) for path in (runtime / "node.exe", runtime / "node") if path.is_file()), None)
if not node:
pytest.skip("Node.js is required for the schedule browser regression")
env = dict(os.environ)
modules = Path(node).resolve().parent.parent / "node_modules"
if modules.is_dir():
env["NODE_PATH"] = os.pathsep.join(value for value in (str(modules), env.get("NODE_PATH")) if value)
probe = subprocess.run([node, "-e", "require.resolve('playwright')"], env=env, capture_output=True, encoding="utf-8", check=False)
if probe.returncode:
pytest.skip("Playwright is required for the schedule browser regression")
with patch.dict(os.environ, {
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false",
"COGNITIVE_AGENT_EXECUTION_MODE": "dry_run",
"COGNITIVE_AGENT_MODE_PROFILE_ROOT": str(tmp_path / "user-modes"),
}):
service = SeatDemoService(REPOSITORY_ROOT)
service.artifact_root = tmp_path / "artifacts"
# A failed deterministic route must fail visibly, never launch a model or
# make network calls to an external assistant during the browser test.
client = MagicMock()
client.available = False
client.status = "test-offline"
assistant = CodexAssistantService(client, fast_implicit_controls=True)
forbidden = []
class BrowserHandler(SeatDemoHandler):
auth = AuthRuntime(AuthConfig(False, "", "", b"", 3600, 5, 300, 120, False))
def do_GET(self) -> None: # noqa: N802
path = urlparse(self.path).path
if path in {"/api/hardware/seat-state", "/api/hardware/vehicle-state"}:
# Both legacy GET paths can touch physical adapters or firmware.
self._json({"status": "unavailable", "hardware_execution_allowed": False})
return
if path == "/api/health":
self._json({"status": "ok", "assistant": {"status": "ready", "available": True}})
return
super().do_GET()
def do_POST(self) -> None: # noqa: N802
path = urlparse(self.path).path
if path == "/api/extension/display-sync":
self._json({"status": "ok"})
return
if path not in {"/api/assistant/stream", "/api/mode-profile-drafts/trial", "/api/mode-profiles"}:
forbidden.append(path)
self._json({"error": "browser_test_write_forbidden"}, HTTPStatus.FORBIDDEN)
return
super().do_POST()
def _read_json_body(self, **kwargs):
payload = super()._read_json_body(**kwargs)
if payload.get("execution_mode", "dry_run") != "dry_run":
forbidden.append(f"execution_mode:{payload.get('execution_mode')}")
raise ValueError("Browser regression permits dry_run only")
return payload
def log_message(self, *_args: object) -> None:
pass
BrowserHandler.service = service
BrowserHandler.assistant = assistant
server = ThreadingHTTPServer(("127.0.0.1", 0), BrowserHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
completed = subprocess.run([
node, str(Path(__file__).with_name("mode_schedule_browser.cjs")),
f"http://127.0.0.1:{server.server_port}", str(tmp_path),
], env=env, capture_output=True, encoding="utf-8", timeout=180, check=False)
assert completed.returncode == 0, completed.stdout + completed.stderr
assert not forbidden, forbidden
assert len(list((tmp_path / "user-modes").rglob("*.yaml"))) == 2
finally:
server.shutdown()
server.server_close()
thread.join(timeout=3)
assistant.close()
service.close()
+10 -10
View File
@@ -621,7 +621,7 @@ operations:
def test_static_console_and_runtime_endpoint(self) -> None: def test_static_console_and_runtime_endpoint(self) -> None:
with self.opener.open(f"{self.base_url}/", timeout=3) as response: with self.opener.open(f"{self.base_url}/", timeout=3) as response:
html = response.read().decode("utf-8") html = response.read().decode("utf-8")
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-131", timeout=3) as response: with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-132", timeout=3) as response:
app_script = response.read().decode("utf-8") app_script = response.read().decode("utf-8")
with self.opener.open( with self.opener.open(
f"{self.base_url}/gesture-control.js?v=android-pad-9", timeout=3 f"{self.base_url}/gesture-control.js?v=android-pad-9", timeout=3
@@ -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-27", timeout=3 f"{self.base_url}/assistant-chat.js?v=codex-assistant-28", 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(
@@ -697,7 +697,7 @@ operations:
self.assertIn("CMD 0x431 · FB 0x41A · CH1", html) self.assertIn("CMD 0x431 · FB 0x41A · CH1", html)
self.assertIn("CMD 0x431 · FB 0x388 · CH1", html) self.assertIn("CMD 0x431 · FB 0x388 · CH1", html)
self.assertNotIn("透明车体 · 实体设备", html) self.assertNotIn("透明车体 · 实体设备", html)
self.assertIn('src="/app.js?v=dual-seat-131"', html) self.assertIn('src="/app.js?v=dual-seat-132"', html)
self.assertIn('href="/styles.css?v=dual-seat-23"', html) self.assertIn('href="/styles.css?v=dual-seat-23"', html)
self.assertIn('src="/gesture-control.js?v=android-pad-9"', html) self.assertIn('src="/gesture-control.js?v=android-pad-9"', html)
self.assertNotIn('class="vehicle-system-hud"', html) self.assertNotIn('class="vehicle-system-hud"', html)
@@ -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-27"', html) self.assertIn('href="/assistant-chat.css?v=codex-assistant-28"', html)
self.assertIn('src="/assistant-chat.js?v=codex-assistant-27"', html) self.assertIn('src="/assistant-chat.js?v=codex-assistant-28"', html)
self.assertIn('href="/energy-monitor.css?v=energy-monitor-4"', html) self.assertIn('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,14 +992,14 @@ 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-v278"', service_worker) self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v279"', service_worker)
self.assertIn('/console-v2.css?v=delivery-state-66', service_worker) self.assertIn('/console-v2.css?v=delivery-state-66', 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-27', service_worker) self.assertIn('/assistant-chat.css?v=codex-assistant-28', service_worker)
self.assertIn('/assistant-chat.js?v=codex-assistant-27', service_worker) self.assertIn('/assistant-chat.js?v=codex-assistant-28', service_worker)
self.assertIn('/energy-monitor.css?v=energy-monitor-4', service_worker) self.assertIn('/energy-monitor.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-131"', service_worker) self.assertIn('"/app.js?v=dual-seat-132"', service_worker)
self.assertIn('"/speech-input.js?v=sensevoice-13"', service_worker) self.assertIn('"/speech-input.js?v=sensevoice-13"', service_worker)
self.assertIn('"/speech-input.css?v=sensevoice-7"', service_worker) self.assertIn('"/speech-input.css?v=sensevoice-7"', service_worker)
self.assertIn('"/speech-audio-worklet.js?v=sensevoice-5"', service_worker) self.assertIn('"/speech-audio-worklet.js?v=sensevoice-5"', service_worker)
@@ -1456,7 +1456,7 @@ operations:
) as response: ) as response:
extension_script = response.read().decode("utf-8") extension_script = response.read().decode("utf-8")
with self.opener.open( with self.opener.open(
f"{self.base_url}/app.js?v=dual-seat-131", timeout=3 f"{self.base_url}/app.js?v=dual-seat-132", timeout=3
) as response: ) as response:
app_script = response.read().decode("utf-8") app_script = response.read().decode("utf-8")
@@ -715,9 +715,16 @@ def test_every_catalog_mode_rejects_incompatible_explicit_seat_scopes(
for mode_index, mode in enumerate(modes): for mode_index, mode in enumerate(modes):
assert isinstance(mode, Mapping) assert isinstance(mode, Mapping)
profile = vehicle_service.mode_profiles.get_profile(str(mode["id"]), "denza_n7")
has_driver = any(
operation.domain == "seat" and operation.target_scope in {"driver", "front_left"}
for operation in profile.operations
)
for scope_index, scope in enumerate( for scope_index, scope in enumerate(
("主驾", "驾驶员", "后排", "二排", "前排", "所有座椅", "全车座椅") ("主驾", "驾驶员", "后排", "二排", "前排", "所有座椅", "全车座椅")
): ):
if has_driver and scope in {"主驾", "驾驶员"}:
continue # Valid dual-seat subsets have a separate exact-action matrix.
message = f"请让{scope}进入{mode['name']}" message = f"请让{scope}进入{mode['name']}"
result = vehicle_service.preview_native_can( result = vehicle_service.preview_native_can(
message, message,
@@ -754,7 +761,7 @@ def test_every_catalog_mode_rejects_incompatible_explicit_seat_scopes(
def test_preview_and_process_share_mode_target_safety_gate( def test_preview_and_process_share_mode_target_safety_gate(
vehicle_service: SeatDemoService, vehicle_service: SeatDemoService,
) -> None: ) -> None:
message = "主驾开启小憩模式" message = "主驾开启零重力模式"
direct = vehicle_service.process( direct = vehicle_service.process(
message, message,
provider="local", provider="local",
@@ -754,7 +754,11 @@ def test_incomplete_ambiguous_or_unsupported_scene_never_creates_partial_draft(
assert result["status"] != "succeeded" assert result["status"] != "succeeded"
assert result.get("mode_profile_draft") is None assert result.get("mode_profile_draft") is None
assert result.get("orchestration_route") != "mode_profile_create_preview" # Seat policy can reject on the creation route before compiling any action.
assert not (result.get("intent_ir") or {}).get("spec", {}).get("actions")
assert not result.get("actions")
assert result.get("command_plan") is None
assert not result.get("command_plans")
assert result.get("validation") assert result.get("validation")
failure = result.get("mode_creation_failure") failure = result.get("mode_creation_failure")
assert isinstance(failure, dict) assert isinstance(failure, dict)
@@ -0,0 +1,471 @@
"""Creation selects one seat by default without remapping incompatible actuators."""
from __future__ import annotations
import json
import queue
from copy import deepcopy
from unittest.mock import MagicMock, patch
import pytest
from jsonschema import Draft202012Validator
from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog
from cognitive_agent.application.codex_assistant import ActiveCodexTurn, CodexAssistantService
from cognitive_agent.application.mode_profile import ModeProfileRegistry
from cognitive_agent.application.scene_composer import normalize_scene_output
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
@pytest.fixture
def policy_service(tmp_path, monkeypatch):
for variable in (
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED",
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED",
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED",
"COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED",
):
monkeypatch.setenv(variable, "false")
service = SeatDemoService(REPOSITORY_ROOT)
service.artifact_root = tmp_path / "artifacts"
user_root = tmp_path / "user-modes"
service.mode_profiles = ModeProfileRegistry(
REPOSITORY_ROOT / "config" / "modes", user_root=user_root,
)
try:
with (
patch.object(service.hardware_execution, "reserve", side_effect=AssertionError("physical CAN forbidden")) as reserve,
patch.object(service._vehicle_mqtt_transport, "bus", side_effect=AssertionError("physical MQTT forbidden")) as mqtt,
):
yield service, AssistantCapabilityCatalog(service), user_root
reserve.assert_not_called()
mqtt.assert_not_called()
finally:
service.close()
def _actions(result):
return (result.get("intent_ir") or {}).get("spec", {}).get("actions", [])
def _seat_scopes(result):
return {
action["target"]["instance"] for action in _actions(result)
if action["domain"] == "seat"
}
def _operation_key(operation):
target = operation.get("target_scope")
if target is None:
raw_target = operation.get("target") or {}
target = raw_target.get("instance") or raw_target.get("zone")
return (
operation["capability"], target,
json.dumps(operation["parameters"], ensure_ascii=False, sort_keys=True),
)
def _preview(service, message, *, plan=None, draft=None):
return service.preview_native_can(
message, provider="local", conversation_id="conv-seat-policy",
execution_mode="hardware", vehicle_id="denza_n7",
assistant_plan=plan, mode_profile_draft_context=draft,
)
def _assert_created(result, scopes):
assert result["status"] == "succeeded", result.get("validation")
assert result.get("mode_profile_draft"), result
assert result["execution_mode"] == "dry_run"
assert result["hardware_execution_allowed"] is False
assert _seat_scopes(result) == set(scopes)
schedule = result.get("schedule")
if schedule:
for phase in schedule["phases"]:
assert {
op["target_scope"] for op in phase["operations"] if op["domain"] == "seat"
} <= set(scopes)
assert result["mode_profile_draft"]["schedule"] == schedule
def _assistant_preview(service, catalog, message, *, client=None):
if client is None:
client = MagicMock()
client.available = False
assistant = CodexAssistantService(client)
snapshot = catalog.snapshot(vehicle_id="denza_n7")
fast = catalog.fast_answer(message, snapshot)
results, events = [], []
def execute(instruction, progress, assistant_plan=None):
result = _preview(service, instruction, plan=assistant_plan)
results.append(result)
return result
response = assistant.stream(
message, conversation_id="conv-seat-policy", principal="seat-policy-test", history=[],
authoritative_answer=fast.answer if fast else None,
emit=lambda event: events.append(event) or True,
execute_control=execute, capability_snapshot=snapshot,
capability_context=catalog.compact_context(snapshot),
scene_capability_context=catalog.scene_composition_context(snapshot),
control_terms=catalog.control_terms(snapshot),
)
assert len(results) == 1, (response, results)
return response, results[0], client
@pytest.mark.parametrize("name", [
"阅读模式", "观影模式", "办公模式", "座椅舒适模式", "放松模式", "座椅动态模式", "上车预热模式",
])
@pytest.mark.parametrize(("prefix", "scopes"), [
("", {"passenger"}), ("主驾", {"driver"}), ("主驾和副驾", {"driver", "passenger"}),
])
def test_registered_modes_use_selected_seat_through_assistant_fast_path(policy_service, name, prefix, scopes):
service, catalog, user_root = policy_service
response, preview, client = _assistant_preview(service, catalog, f"构建一个{prefix}{name}")
assert response["success"] is True, (response, preview)
_assert_created(preview, scopes)
client.start_thread.assert_not_called()
client.start_turn.assert_not_called()
client.start_structured_turn.assert_not_called()
assert not list(user_root.rglob("*.yaml"))
@pytest.mark.parametrize(("message", "scopes"), [
("构建一个两个座椅的阅读模式", {"driver", "passenger"}),
("构建一个两张座椅的阅读模式", {"driver", "passenger"}),
("构建一个两座椅的阅读模式", {"driver", "passenger"}),
("构建一个前排两座的阅读模式", {"driver", "passenger"}),
("构建一个副驾驶席阅读模式", {"passenger"}),
("构建一个副驾驶位阅读模式", {"passenger"}),
("构建一个副驾驶座椅阅读模式", {"passenger"}),
("构建一个两个座椅的动态模式", {"driver", "passenger"}),
("构建一个两张座椅的动态模式", {"driver", "passenger"}),
("构建一个两座椅的动态模式", {"driver", "passenger"}),
("构建一个前排两座的动态模式", {"driver", "passenger"}),
("构建一个副驾驶座椅动态模式", {"passenger"}),
])
def test_registered_seat_aliases_select_correct_targets_without_model(policy_service, message, scopes):
service, catalog, _ = policy_service
response, preview, client = _assistant_preview(service, catalog, message)
assert response["success"] is True, (
response["answer"], preview.get("validation"), preview.get("mode_profile_draft"),
)
_assert_created(preview, scopes)
assert response["route"] == "scene_template"
client.start_structured_turn.assert_not_called()
client.start_turn.assert_not_called()
@pytest.mark.parametrize("template_id", [
"reading", "cinema", "camping", "quiet-night", "reception", "romantic",
"winter-warmth", "summer-cooling", "long-trip-comfort", "relaxation", "seat-comfort",
"seat-dynamic", "boarding-warmup",
])
@pytest.mark.parametrize(("prefix", "scope"), [("", "passenger"), ("主驾", "driver")])
def test_trusted_template_selection_keeps_nonseat_devices_and_native_parameters(policy_service, template_id, prefix, scope):
service, _, _ = policy_service
template = service.scene_templates.get_template(template_id)
original = deepcopy(template.plan())
result = _preview(service, f"构建一个{prefix}{template.name}", plan=template.plan())
_assert_created(result, {scope})
raw_operations = (
[op for phase in original["schedule"]["phases"] for op in phase["operations"]]
if original.get("schedule") else [op.to_yaml() for op in template.operations]
)
expected = [
_operation_key(op) for op in raw_operations
if op["domain"] != "seat" or op["target_scope"] == scope
]
assert sorted(map(_operation_key, _actions(result))) == sorted(expected)
assert template.plan() == original, "projecting a request must not mutate the registered template"
def _model_output(scopes, *, dynamic=False):
requests = []
if "driver" in scopes:
requests.append({
"capability": "seat.set_driver_massage_native", "target_scope": "driver",
"parameters": {"action": "mode", "mode": "relax"},
})
if "passenger" in scopes:
requests.append({
"capability": "seat.set_backrest_recline", "target_scope": "passenger",
"parameters": {"position_pct": 45},
})
requests.append({
"capability": "climate.set_temperature", "target_scope": "vehicle",
"parameters": {"temperature_c": 24},
})
if not dynamic:
return {"requests": requests, "ambiguities": [], "confidence": 0.95}
return {
"requests": [], "ambiguities": [], "confidence": 0.95,
"schedule": {
"repeat_count": 2,
"phases": [
{"id": "relax", "name": "舒缓", "duration_ms": 30000, "requests": deepcopy(requests)},
{"id": "support", "name": "支撑", "duration_ms": 60000, "requests": deepcopy(requests)},
],
},
}
@pytest.mark.parametrize("dynamic", [False, True])
@pytest.mark.parametrize(("prefix", "scopes"), [
("", {"passenger"}), ("主驾", {"driver"}), ("主驾和副驾", {"driver", "passenger"}),
])
def test_unregistered_model_plan_preserves_correct_explicit_or_default_targets(policy_service, dynamic, prefix, scopes):
service, catalog, _ = policy_service
plan = normalize_scene_output(
_model_output(scopes, dynamic=dynamic), catalog.scene_composition_context(), dynamic=dynamic,
)
dynamic_label = "动态" if dynamic else ""
result = _preview(service, f"构建一个{prefix}星河释压{dynamic_label}模式", plan=plan)
_assert_created(result, scopes)
assert result["mode_profile_draft"]["generation_source"] == "codex_scene_composition"
@pytest.mark.parametrize("dynamic", [False, True])
@pytest.mark.parametrize("prefix", ["", "主驾", "副驾"])
def test_unregistered_model_cannot_invent_cross_seat_actions(policy_service, dynamic, prefix):
service, catalog, user_root = policy_service
plan = normalize_scene_output(
_model_output({"driver", "passenger"}, dynamic=dynamic),
catalog.scene_composition_context(), dynamic=dynamic,
)
try:
result = _preview(service, f"构建一个{prefix}星河释压{'动态' if dynamic else ''}模式", plan=plan)
except ValueError:
pass
else:
assert result["status"] != "succeeded"
assert not result.get("mode_profile_draft")
assert not _actions(result)
assert result["hardware_execution_allowed"] is False
assert not list(user_root.rglob("*.yaml"))
@pytest.mark.parametrize(("prefix", "requested_scope", "unexpected_scope"), [
("", "passenger", "driver"), ("主驾", "driver", "passenger"),
])
def test_unexpected_seat_only_in_later_dynamic_phase_rejects_entire_draft(
policy_service, prefix, requested_scope, unexpected_scope,
):
service, catalog, user_root = policy_service
output = _model_output({requested_scope}, dynamic=True)
output["schedule"]["phases"][1]["requests"] = _model_output({unexpected_scope})["requests"]
plan = normalize_scene_output(output, catalog.scene_composition_context(), dynamic=True)
try:
result = _preview(service, f"构建一个{prefix}星河释压动态模式", plan=plan)
except ValueError:
pass
else:
assert result["status"] != "succeeded"
assert not result.get("mode_profile_draft")
assert not _actions(result)
assert not result.get("command_plans")
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
assert not list(user_root.rglob("*.yaml"))
@pytest.mark.parametrize("message", [
"构建一个办公模式,空调先24度持续30秒,然后24度持续60秒,循环2次",
"构建一个办公模式,主驾座椅分两个阶段动态舒缓",
])
def test_existing_mode_binding_cannot_authorize_cross_seat_model_schedule(policy_service, message):
service, catalog, user_root = policy_service
original = service.mode_profiles.get_profile("office", "denza_n7")
revision = service.mode_profiles.profile_revision(original)
plan = normalize_scene_output(
_model_output({"driver", "passenger"}, dynamic=True),
catalog.scene_composition_context(), dynamic=True,
)
try:
result = _preview(service, message, plan=plan)
except ValueError:
pass
else:
assert result["status"] != "succeeded", "binding an existing mode is not authorization for model-added seats"
assert not result.get("mode_profile_draft")
assert not _actions(result)
assert not result.get("command_plans")
assert service.mode_profiles.profile_revision(service.mode_profiles.get_profile("office", "denza_n7")) == revision
assert not list(user_root.rglob("*.yaml"))
@pytest.mark.parametrize(("message", "scope"), [
("构建一个办公模式,空调先24度持续30秒,然后24度持续60秒,循环2次", "passenger"),
("构建一个办公模式,主驾座椅分两个阶段动态舒缓", "driver"),
])
def test_existing_mode_binding_keeps_valid_selected_seat_schedule_saveable(policy_service, message, scope):
service, catalog, _ = policy_service
plan = normalize_scene_output(
_model_output({scope}, dynamic=True), catalog.scene_composition_context(), dynamic=True,
)
result = _preview(service, message, plan=plan)
_assert_created(result, {scope})
draft = result["mode_profile_draft"]
assert draft["existing_mode_id"] == "office"
assert draft["existing_mode_revision"]
saved = service.save_mode_profile_card(
draft["existing_mode_id"], draft["name"], draft["instruction"],
draft["source_request_id"], draft["vehicle_id"],
)
assert saved["status"] == "succeeded"
assert saved["mode_profile"]["schedule"] == result["schedule"]
executed = service.execute_mode_profile_card(
saved["mode_profile"]["id"], execution_mode="dry_run", vehicle_id="denza_n7",
)
assert executed["status"] == "succeeded"
assert _seat_scopes(executed) == {scope}
@pytest.mark.parametrize("dynamic", [False, True])
def test_default_seat_preference_does_not_force_seat_actions_into_nonseat_scene(policy_service, dynamic):
service, catalog, _ = policy_service
plan = normalize_scene_output(
_model_output(set(), dynamic=dynamic), catalog.scene_composition_context(), dynamic=dynamic,
)
result = _preview(service, f"构建一个星河恒温{'动态' if dynamic else ''}模式", plan=plan)
_assert_created(result, set())
assert all(action["domain"] == "climate" for action in _actions(result))
def test_explicit_atomic_mode_requirements_default_unqualified_seat_to_passenger(policy_service):
service, _, _ = policy_service
result = _preview(service, "构建一个自定义支撑模式,座椅靠背调整到45%,空调24度")
_assert_created(result, {"passenger"})
assert {_operation_key(action) for action in _actions(result)} == {
("seat.set_backrest_recline", "passenger", '{"position_pct": 45}'),
# Only the seat default changes; the existing local HVAC zone stays driver.
("climate.set_temperature", "driver", '{"temperature_c": 24}'),
}
@pytest.mark.parametrize("dynamic", [False, True])
def test_passenger_position_capability_is_not_retargeted_to_driver(policy_service, dynamic):
service, catalog, _ = policy_service
output = _model_output({"passenger"}, dynamic=dynamic)
requests = output["schedule"]["phases"][0]["requests"] if dynamic else output["requests"]
requests[0]["target_scope"] = "driver"
try:
plan = normalize_scene_output(output, catalog.scene_composition_context(), dynamic=dynamic)
result = _preview(service, f"构建一个主驾星河释压{'动态' if dynamic else ''}模式", plan=plan)
except ValueError:
return
assert result["status"] != "succeeded"
assert not result.get("mode_profile_draft")
assert not _actions(result)
@pytest.mark.parametrize(("message", "scopes"), [
("构建一个主驾空调与座椅舒适模式", {"passenger"}),
("构建一个主驾阅读灯与座椅舒适模式", {"passenger"}),
("构建一个副驾空调与主驾座椅舒适模式", {"driver"}),
("构建一个主驾阅读灯与副驾座椅舒适模式", {"passenger"}),
])
def test_other_device_zones_do_not_select_or_broaden_seat_scope(policy_service, message, scopes):
service, catalog, _ = policy_service
plan = normalize_scene_output(_model_output(scopes), catalog.scene_composition_context())
result = _preview(service, message, plan=plan)
_assert_created(result, scopes)
@pytest.mark.parametrize(("template_id", "prefix", "scope"), [
("reading", "", "passenger"), ("reading", "主驾", "driver"),
("seat-dynamic", "", "passenger"), ("seat-dynamic", "主驾", "driver"),
("boarding-warmup", "", "passenger"), ("boarding-warmup", "主驾", "driver"),
])
def test_save_reload_and_simulated_execution_preserve_selected_seat(policy_service, template_id, prefix, scope):
service, _, user_root = policy_service
template = service.scene_templates.get_template(template_id)
preview = _preview(service, f"构建一个{prefix}{template.name}", plan=template.plan())
_assert_created(preview, {scope})
draft = preview["mode_profile_draft"]
saved = service.create_mode_profile_card(
draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"],
)
assert saved["status"] == "succeeded", saved
service.mode_profiles = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes", user_root=user_root)
executed = service.execute_mode_profile_card(
saved["mode_profile"]["id"], conversation_id="conv-seat-policy-replay",
execution_mode="dry_run", vehicle_id="denza_n7",
)
assert executed["status"] == "succeeded", executed.get("validation")
assert executed["hardware_execution_allowed"] is False
assert _seat_scopes(executed) == {scope}
assert sorted(map(_operation_key, _actions(executed))) == sorted(map(_operation_key, _actions(preview)))
if preview.get("schedule"):
assert executed["schedule"] == preview["schedule"]
assert executed["schedule_execution"]["completed"] is True
assert all(
_seat_scopes(phase["result"]) <= {scope}
for phase in executed["schedule_execution"]["phase_results"]
)
@pytest.mark.parametrize(("initial_scope", "prefix"), [("driver", "主驾"), ("passenger", "")])
def test_unrelated_draft_edit_retains_original_seat_and_explicit_edit_may_extend_it(policy_service, initial_scope, prefix):
service, catalog, _ = policy_service
name = f"{prefix}星河释压模式"
built = _preview(
service, f"构建一个{name}",
plan=normalize_scene_output(_model_output({initial_scope}), catalog.scene_composition_context()),
)
_assert_created(built, {initial_scope})
draft = built["mode_profile_draft"]
revised = _preview(
service, f"把{name}的空调温度调整到26度",
draft={key: draft[key] for key in ("name", "source_request_id", "vehicle_id")},
)
_assert_created(revised, {initial_scope})
original_seat_ops = {_operation_key(a) for a in _actions(built) if a["domain"] == "seat"}
assert {_operation_key(a) for a in _actions(revised) if a["domain"] == "seat"} == original_seat_ops
added = "副驾座椅通风调整到1档" if initial_scope == "driver" else "主驾座椅通风调整到低档"
draft = revised["mode_profile_draft"]
extended = _preview(
service, f"把{name}的{added}",
draft={key: draft[key] for key in ("name", "source_request_id", "vehicle_id")},
)
_assert_created(extended, {"driver", "passenger"})
assert {_operation_key(a) for a in _actions(extended) if a["domain"] == "seat"} >= original_seat_ops
def _structured_client(output):
client = MagicMock()
client.available = True
client.status = "ready"
client.start_thread.return_value = "thread-seat-policy"
client.start_turn.side_effect = AssertionError("scene fell back to unconstrained agent")
def start(thread_id, text, *, application_context, output_schema, timeout=None):
Draft202012Validator(output_schema).validate(output)
turn = ActiveCodexTurn(thread_id=thread_id, turn_id="turn-seat-policy", events=queue.Queue())
turn.events.put({"method": "item/completed", "params": {"item": {
"id": "answer", "type": "agentMessage", "text": json.dumps(output, ensure_ascii=False),
}}})
turn.events.put({"method": "turn/completed", "params": {"turn": {
"id": turn.turn_id, "status": "completed", "items": [], "error": None,
}}})
return turn
client.start_structured_turn.side_effect = start
return client
@pytest.mark.parametrize(("prefix", "scopes"), [("", {"passenger"}), ("主驾", {"driver"})])
@pytest.mark.parametrize("dynamic", [False, True])
def test_unregistered_assistant_structured_path_uses_seat_policy(policy_service, prefix, scopes, dynamic):
service, catalog, _ = policy_service
client = _structured_client(_model_output(scopes, dynamic=dynamic))
response, preview, _ = _assistant_preview(
service, catalog, f"构建一个{prefix}星河释压{'动态' if dynamic else ''}模式", client=client,
)
assert response["success"] is True, (response, preview)
assert response["route"] == "scene_composer"
_assert_created(preview, scopes)
client.start_structured_turn.assert_called_once()
client.start_turn.assert_not_called()
@@ -1237,6 +1237,10 @@ def test_known_mode_name_reuses_repository_profile_even_if_a_plan_is_supplied(
) -> None: ) -> None:
service, user_root = mode_service service, user_root = mode_service
nap = service.mode_profiles.get_profile("nap", "denza_n7") nap = service.mode_profiles.get_profile("nap", "denza_n7")
selected_operations = [
operation for operation in nap.operations
if operation.domain != "seat" or operation.target_scope in {"passenger", "front_right"}
]
result = _preview_without_hardware( result = _preview_without_hardware(
service, service,
"构建一个小憩模式", "构建一个小憩模式",
@@ -1249,15 +1253,16 @@ def test_known_mode_name_reuses_repository_profile_even_if_a_plan_is_supplied(
draft = _assert_successful_preview( draft = _assert_successful_preview(
result, result,
name=nap.name, name=nap.name,
operation_count=len(nap.operations), operation_count=len(selected_operations),
) )
assert draft["generation_source"] == "registered_mode_profile" assert draft["generation_source"] == "registered_mode_profile"
assert draft["existing_mode_id"] == "nap" assert draft["existing_mode_id"] == "nap"
expected = { expected = {
(operation.capability, operation.target_scope): operation.parameters (operation.capability, operation.target_scope): operation.parameters
for operation in nap.operations for operation in selected_operations
} }
actual = _action_map(result) actual = _action_map(result)
assert {target for capability, target in actual if capability.startswith("seat.")} == {"passenger"}
for (capability, target), parameters in expected.items(): for (capability, target), parameters in expected.items():
rendered_target = "passenger" if target == "front_right" and capability.startswith("seat.") else target rendered_target = "passenger" if target == "front_right" and capability.startswith("seat.") else target
for name, value in parameters.items(): for name, value in parameters.items():
@@ -0,0 +1,670 @@
"""Dynamic scene creation through the real assistant, compiler and saved-mode APIs."""
from __future__ import annotations
import json
import queue
from copy import deepcopy
from threading import Event
from unittest.mock import MagicMock, patch
import pytest
from jsonschema import Draft202012Validator
from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog
from cognitive_agent.application.codex_assistant import ActiveCodexTurn, CodexAssistantService
from cognitive_agent.application.mode_profile import ModeProfileRegistry
from cognitive_agent.application.mode_schedule import ModeSchedule
from cognitive_agent.application.mode_schedule_service import public_schedule_status
from cognitive_agent.application.scene_composer import (
SceneCompositionError,
build_scene_output_schema,
normalize_scene_output,
)
from cognitive_agent.application.schedule_intent import needs_mode_schedule
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
from cognitive_agent.infrastructure.can.access import HardwareAccessRequiredError
@pytest.fixture
def schedule_service(tmp_path, monkeypatch):
for variable in (
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED", "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED",
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED", "COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED",
):
monkeypatch.setenv(variable, "false")
service = SeatDemoService(REPOSITORY_ROOT)
service.artifact_root = tmp_path / "artifacts"
user_root = tmp_path / "user-modes"
service.mode_profiles = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes", user_root=user_root)
with (
patch.object(service.hardware_execution, "reserve", side_effect=AssertionError("physical vehicle access forbidden")) as reserve,
patch.object(service._vehicle_mqtt_transport, "bus", side_effect=AssertionError("physical MQTT access forbidden")),
):
try:
yield service, AssistantCapabilityCatalog(service), user_root
finally:
service.close()
reserve.assert_not_called()
def assistant_preview(service, catalog, message, conversation="conv-schedule-flow", client=None):
if client is None:
client = MagicMock()
client.available = False
assistant = CodexAssistantService(client)
snapshot = catalog.snapshot(vehicle_id="denza_n7")
fast = catalog.fast_answer(message, snapshot)
results, events, passed_plans = [], [], []
def execute(instruction, progress, assistant_plan=None):
passed_plans.append(deepcopy(assistant_plan))
result = service.preview_native_can(
instruction, provider="local", conversation_id=conversation,
execution_mode="hardware", vehicle_id="denza_n7",
on_commands_validated=progress, assistant_plan=assistant_plan,
)
results.append(result)
return result
response = assistant.stream(
message, conversation_id=conversation, principal="schedule-test", history=[],
authoritative_answer=fast.answer if fast else None,
emit=lambda event: events.append(event) or True,
execute_control=execute, capability_snapshot=snapshot,
capability_context=catalog.compact_context(snapshot),
scene_capability_context=catalog.scene_composition_context(snapshot),
control_terms=catalog.control_terms(snapshot),
)
assert len(results) == 1, (response, results)
return response, results[0], events, passed_plans, client
def create_saved(service, catalog, message="构建一个上车预热的功能模式"):
response, preview, _, _, _ = assistant_preview(service, catalog, message)
assert response["success"] is True, (response, preview)
draft = preview["mode_profile_draft"]
saved = service.create_mode_profile_card(
draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"],
)
assert saved["status"] == "succeeded"
return preview, saved["mode_profile"]
@pytest.mark.parametrize(("message", "template_id", "count", "repeats", "elapsed"), [
("构建一个座椅的动态模式", "seat-dynamic", 4, 5, 300000),
("构建一个上车预热的功能模式", "boarding-warmup", 7, 1, 420000),
("帮我创建一个座椅动态模式吧", "seat-dynamic", 4, 5, 300000),
("我想构建一个上车预热模式并预览", "boarding-warmup", 7, 1, 420000),
("构建一个上车暖舱模式", "boarding-warmup", 7, 1, 420000),
("构建一个空调预热模式", "boarding-warmup", 7, 1, 420000),
("构建一个动态座椅模式", "seat-dynamic", 4, 5, 300000),
("构建一个座椅交替放松模式", "seat-dynamic", 4, 5, 300000),
])
def test_user_temporal_requests_create_complete_multistage_template_without_model(
schedule_service, message, template_id, count, repeats, elapsed,
):
service, catalog, user_root = schedule_service
response, preview, events, plans, client = assistant_preview(service, catalog, message)
assert response["success"] is True, (response, preview)
assert response["route"] == "scene_template"
assert preview["status"] == "succeeded"
assert preview["execution_mode"] == "dry_run"
assert preview["hardware_execution_allowed"] is False
assert preview["orchestration_route"] == "mode_profile_create_preview"
draft = preview["mode_profile_draft"]
assert draft["source_template_id"] == template_id
assert draft["generation_source"] == "scene_template_copy"
assert draft["operation_count"] == count
expected_schedule = deepcopy(plans[0]["schedule"])
for phase in expected_schedule["phases"]:
phase["operations"] = [
op for op in phase["operations"]
if op["domain"] != "seat" or op["target_scope"] == "passenger"
]
assert draft["schedule"] == preview["schedule"] == expected_schedule
schedule = ModeSchedule.from_dict(preview["schedule"])
assert schedule.repeat_count == repeats
assert schedule.total_duration_ms == elapsed
actions = preview["intent_ir"]["spec"]["actions"]
assert len(actions) == count
assert len({action["id"] for action in actions}) == count
assert {item["phase_id"] for item in preview["schedule_phase_previews"]} == {phase.id for phase in schedule.phases}
assert len(preview["schedule_phase_previews"]) == 2
assert all(item["status"] == "succeeded" for item in preview["schedule_phase_previews"])
assert all(any(key.startswith(phase.id + "-") for key in preview["command_plans"]) for phase in schedule.phases)
assert {
op["target_scope"] for phase in schedule.phases for op in phase.operations
if op["domain"] == "seat"
} == {"passenger"}
assert events
client.start_thread.assert_not_called()
client.start_turn.assert_not_called()
client.start_structured_turn.assert_not_called()
assert not list(user_root.rglob("*.yaml"))
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
@pytest.mark.parametrize("message", ["构建一个座椅的动态模式", "构建一个上车预热的功能模式"])
def test_draft_trial_preserves_all_stages_repeats_and_virtual_time_without_thread(schedule_service, message):
service, catalog, _ = schedule_service
_, preview, _, _, _ = assistant_preview(service, catalog, message)
draft = preview["mode_profile_draft"]
with patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("dry-run spawned worker")):
trial = service.trial_mode_profile_draft(
draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"],
)
assert trial["status"] == "succeeded"
assert trial["hardware_execution_allowed"] is False
assert trial["schedule"] == preview["schedule"]
execution = trial["schedule_execution"]
schedule = ModeSchedule.from_dict(preview["schedule"])
assert execution["completed"] is True
assert execution["virtual_elapsed_ms"] == schedule.total_duration_ms
assert execution["executed_phases"] == len(schedule.phases) * schedule.repeat_count
assert [step["phase_id"] for step in execution["phase_results"]] == [phase.id for phase in schedule.phases] * schedule.repeat_count
assert len(trial["intent_ir"]["spec"]["actions"]) == schedule.operation_count
@pytest.mark.parametrize("message", ["构建一个座椅的动态模式", "构建一个上车预热的功能模式"])
def test_save_reload_and_direct_execute_keep_temporal_semantics(schedule_service, message):
service, catalog, user_root = schedule_service
preview, public_profile = create_saved(service, catalog, message)
assert public_profile["schedule"] == preview["schedule"]
assert public_profile["behavior"] == "cyclic"
mode_id = public_profile["id"]
service.mode_profiles = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes", user_root=user_root)
reloaded = service.mode_profiles.get_profile(mode_id, "denza_n7")
assert reloaded.schedule.to_dict() == preview["schedule"]
with patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("dry-run spawned worker")):
executed = service.execute_mode_profile_card(
mode_id, conversation_id="conv-schedule-execute", execution_mode="dry_run", vehicle_id="denza_n7",
)
assert executed["status"] == "succeeded", executed
assert executed["orchestration_route"] == "mode_schedule_execute"
assert executed["execution_mode"] == "dry_run"
assert executed["hardware_execution_allowed"] is False
assert "mode_profile_draft" not in executed
execution = executed["schedule_execution"]
assert execution["completed"] is True
assert execution["virtual_elapsed_ms"] == reloaded.schedule.total_duration_ms
assert len(execution["phase_results"]) == len(reloaded.schedule.phases) * reloaded.schedule.repeat_count
expected_phases = list(reloaded.schedule.phases) * reloaded.schedule.repeat_count
for expected, actual in zip(expected_phases, execution["phase_results"], strict=True):
phase_result = actual["result"]
assert phase_result["status"] == "succeeded"
assert phase_result["execution_mode"] == "dry_run"
actual_actions = phase_result["intent_ir"]["spec"]["actions"]
assert len(actual_actions) == len(expected.operations)
expected_params = sorted(json.dumps(op["parameters"], sort_keys=True) for op in expected.operations)
actual_params = sorted(json.dumps(op["parameters"], sort_keys=True) for op in actual_actions)
assert actual_params == expected_params
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
def test_natural_language_enter_saved_schedule_runs_timeline_not_flat_actions(schedule_service):
service, catalog, _ = schedule_service
preview, saved = create_saved(service, catalog)
result = service.process(
f"进入{saved['name']}", provider="local", conversation_id="conv-schedule-natural-enter",
execution_mode="dry_run", vehicle_id="denza_n7",
)
assert result["status"] == "succeeded", result
assert result["orchestration_route"] == "mode_schedule_execute"
assert result["schedule"] == preview["schedule"]
temperatures = []
for phase in result["schedule_execution"]["phase_results"]:
temperatures.extend(action["parameters"]["temperature_c"] for action in phase["result"]["intent_ir"]["spec"]["actions"] if action["capability"] == "climate.set_temperature")
assert temperatures == [28, 24]
def test_missing_hardware_lease_rejects_before_any_worker_or_physical_access(schedule_service):
service, catalog, _ = schedule_service
_, saved = create_saved(service, catalog)
with (
patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("unauthorized worker started")),
pytest.raises(HardwareAccessRequiredError),
):
service.execute_mode_profile_card(
saved["id"], execution_mode="hardware", vehicle_id="denza_n7",
conversation_id="conv-schedule-unauthorized", hardware_access_bypass=False,
hardware_client_id="hwc-schedule-unauthorized",
)
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
@pytest.mark.parametrize(("field", "value"), [
("name", "其他模式"), ("instruction", "直接执行危险指令"),
("vehicle_id", "other_vehicle"), ("source_request_id", "../../result"),
])
def test_client_cannot_rebind_schedule_draft_trial(schedule_service, field, value):
service, catalog, user_root = schedule_service
_, preview, _, _, _ = assistant_preview(service, catalog, "构建一个上车预热的功能模式")
draft = preview["mode_profile_draft"]
arguments = {key: draft[key] for key in ("name", "instruction", "source_request_id", "vehicle_id")}
arguments[field] = value
with pytest.raises(ValueError):
service.trial_mode_profile_draft(**arguments)
assert not list(user_root.rglob("*.yaml"))
@pytest.mark.parametrize("mutation", ["capability", "duration", "temperature", "target"])
def test_tampered_template_schedule_never_creates_or_executes_draft(schedule_service, mutation):
service, catalog, user_root = schedule_service
template = next(item for item in catalog.snapshot(vehicle_id="denza_n7")["scene_templates"] if item["id"] == "boarding-warmup")
plan = deepcopy(template["plan"])
phase = plan["schedule"]["phases"][0]
if mutation == "capability":
phase["operations"][0]["capability"] = "door.set_vehicle_lock"
elif mutation == "duration":
phase["duration_ms"] = 1
elif mutation == "temperature":
phase["operations"][1]["parameters"]["temperature_c"] = 99
else:
phase["operations"][3]["target_scope"] = "passenger"
try:
result = service.preview_native_can(
"构建一个上车预热的功能模式", provider="local", conversation_id="conv-schedule-tamper",
execution_mode="hardware", vehicle_id="denza_n7", assistant_plan=plan,
)
except ValueError:
result = None
if result is not None:
assert result["status"] != "succeeded"
assert "mode_profile_draft" not in result
assert result.get("hardware_execution_allowed") is not True
assert not list(user_root.rglob("*.yaml"))
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
def test_invalid_later_saved_phase_is_revalidated_before_any_phase_executes(schedule_service):
service, catalog, _ = schedule_service
_, saved = create_saved(service, catalog)
profile = service.mode_profiles.get_profile(saved["id"], "denza_n7")
# Represents a stale or externally edited user profile, not a trusted runtime
# capability change. Its structure remains valid, but the target is unsafe.
profile.schedule.phases[1].operations[0]["parameters"]["temperature_c"] = 99
with patch.object(service.mode_schedule_runner, "simulate", side_effect=AssertionError("invalid later phase reached executor")):
result = service.execute_mode_profile_card(
saved["id"], conversation_id="conv-schedule-stale-capability",
execution_mode="dry_run", vehicle_id="denza_n7",
)
assert result["status"] == "validation_failed", result
assert result["mode_creation_failure"]["code"] == "MODE_SCHEDULE_PHASE_INVALID"
assert "mode_profile_draft" not in result
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
def test_execution_failure_after_validated_first_phase_prevents_later_repeats(schedule_service):
service, catalog, _ = schedule_service
_, saved = create_saved(service, catalog, "构建一个主驾和副驾的座椅动态模式")
preview_method = service._preview_mode_schedule
virtual_method = service.native_can_runtime.execute_virtual
execution_started = False
executed_massage_modes = []
def validate_then_arm(*args, **kwargs):
nonlocal execution_started
result = preview_method(*args, **kwargs)
execution_started = True
return result
def fault_on_second_phase(command_plan, function_config):
if execution_started:
for op in function_config["spec"]["operations"]:
if op["capability"] == "seat.set_driver_massage_native":
executed_massage_modes.append(op["parameters"]["mode"])
if op["parameters"]["mode"] == "slack":
raise RuntimeError("injected lower-level actuator failure")
return virtual_method(command_plan, function_config)
with (
patch.object(service, "_preview_mode_schedule", side_effect=validate_then_arm),
patch.object(service.native_can_runtime, "execute_virtual", side_effect=fault_on_second_phase),
):
result = service.execute_mode_profile_card(
saved["id"], conversation_id="conv-schedule-injected-failure",
execution_mode="dry_run", vehicle_id="denza_n7",
)
execution = result["schedule_execution"]
assert result["status"] == "failed", result
assert execution["completed"] is False
assert execution["error"]
assert execution["executed_phases"] == 1
assert execution["virtual_elapsed_ms"] == 30000
assert execution["repeat_index"] == 0
assert len(execution["phase_results"]) == 2
assert executed_massage_modes == ["relax", "slack"]
assert result["hardware_execution_allowed"] is False
def structured_schedule():
return {
"requests": [], "ambiguities": [], "confidence": 0.96,
"schedule": {
"repeat_count": 2,
"phases": [
{"id": "warm", "name": "暖舱", "duration_ms": 30000,
"requests": [{"capability": "climate.set_temperature", "target_scope": "vehicle", "parameters": {"temperature_c": 28}}]},
{"id": "settle", "name": "恒温", "duration_ms": 60000,
"requests": [{"capability": "climate.set_temperature", "target_scope": "vehicle", "parameters": {"temperature_c": 24}}]},
],
},
}
def structured_client(output):
client = MagicMock()
client.available = True
client.status = "ready"
client.start_thread.return_value = "thread-schedule-test"
client.start_turn.side_effect = AssertionError("dynamic creation fell back to unstructured agent")
def start(thread_id, text, *, application_context, output_schema, timeout=None):
Draft202012Validator(output_schema).validate(output)
turn = ActiveCodexTurn(thread_id=thread_id, turn_id="turn-schedule-test", events=queue.Queue())
turn.events.put({"method": "item/completed", "params": {"item": {"id": "answer", "type": "agentMessage", "text": json.dumps(output, ensure_ascii=False)}}})
turn.events.put({"method": "turn/completed", "params": {"turn": {"id": turn.turn_id, "status": "completed", "items": [], "error": None}}})
return turn
client.start_structured_turn.side_effect = start
return client
@pytest.mark.parametrize("message", [
"构建一个星云动态模式,空调先28度持续30秒,然后24度持续60秒,循环2次",
"构建一个阅读模式,空调先28度持续30秒,然后24度持续60秒,循环2次",
"构建一个上车预热的功能模式,空调先28度持续30秒,然后24度持续60秒,循环2次",
])
def test_explicit_temporal_parameters_use_structured_composition_not_static_template(schedule_service, message):
service, catalog, user_root = schedule_service
output = structured_schedule()
client = structured_client(output)
response, preview, _, plans, _ = assistant_preview(service, catalog, message, client=client)
assert response["route"] == "scene_composer", response
assert response["success"] is True, (response, preview)
assert preview["mode_profile_draft"]["generation_source"] != "scene_template_copy"
client.start_structured_turn.assert_called_once()
client.start_turn.assert_not_called()
assert "schedule" in client.start_structured_turn.call_args.kwargs["output_schema"]["properties"]
assert preview["schedule"] == plans[0]["schedule"]
assert preview["schedule"]["repeat_count"] == 2
assert [phase["duration_ms"] for phase in preview["schedule"]["phases"]] == [30000, 60000]
assert [phase["operations"][0]["parameters"]["temperature_c"] for phase in preview["schedule"]["phases"]] == [28, 24]
assert ModeSchedule.from_dict(preview["schedule"]).total_duration_ms == 180000
assert not list(user_root.rglob("*.yaml"))
def test_dynamic_schema_normalization_preserves_complete_phase_contract(schedule_service):
_, catalog, _ = schedule_service
context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7"))
output = structured_schedule()
schema = build_scene_output_schema(context, dynamic=True)
Draft202012Validator(schema).validate(output)
plan = normalize_scene_output(output, context, dynamic=True)
assert plan["ambiguities"] == []
schedule = ModeSchedule.from_dict(plan["schedule"])
assert schedule.total_duration_ms == 180000
assert schedule.operation_count == 2
assert schedule.phases[0].operations[0]["domain"] == "climate"
assert schedule.phases[1].operations[0]["parameters"] == {"temperature_c": 24}
@pytest.mark.parametrize("mutation", [
"static-root", "unknown-root", "unknown-schedule", "unknown-phase", "unknown-capability",
"duplicate-phase", "duplicate-operation", "no-phases", "empty-phase", "bool-duration",
"bool-repeat", "long-duration", "long-total", "nan-parameter", "motion-capability",
])
def test_invalid_structured_dynamic_plans_fail_closed_during_normalization(schedule_service, mutation):
_, catalog, _ = schedule_service
context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7"))
output = structured_schedule()
schedule = output["schedule"]
phase = schedule["phases"][0]
if mutation == "static-root":
output["requests"] = deepcopy(phase["requests"])
elif mutation == "unknown-root":
output["execute_now"] = True
elif mutation == "unknown-schedule":
schedule["unbounded"] = True
elif mutation == "unknown-phase":
phase["skip_safety"] = True
elif mutation == "unknown-capability":
phase["requests"][0]["capability"] = "unknown.actuate"
elif mutation == "motion-capability":
phase["requests"][0] = {"capability": "seat.set_driver_axis_native", "target_scope": "driver", "parameters": {"duration_ms": 1000, "axis": "backrest", "direction": "backward"}}
elif mutation == "duplicate-phase":
schedule["phases"][1]["id"] = phase["id"]
elif mutation == "duplicate-operation":
phase["requests"].append(deepcopy(phase["requests"][0]))
elif mutation == "no-phases":
schedule["phases"] = []
elif mutation == "empty-phase":
phase["requests"] = []
elif mutation == "bool-duration":
phase["duration_ms"] = True
elif mutation == "bool-repeat":
schedule["repeat_count"] = True
elif mutation == "long-duration":
phase["duration_ms"] = 900001
elif mutation == "long-total":
schedule["repeat_count"] = 20
phase["duration_ms"] = 900000
elif mutation == "nan-parameter":
phase["requests"][0]["parameters"]["temperature_c"] = float("nan")
with pytest.raises(SceneCompositionError):
normalize_scene_output(output, context, dynamic=True)
def test_dynamic_ambiguity_produces_no_schedule_or_executable_actions(schedule_service):
_, catalog, _ = schedule_service
context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7"))
output = {"requests": [], "ambiguities": ["当前未开放该动态动作,请指定其他设备。"], "confidence": 0.2, "schedule": None}
Draft202012Validator(build_scene_output_schema(context, dynamic=True)).validate(output)
plan = normalize_scene_output(output, context, dynamic=True)
assert not plan["requests"]
assert "schedule" not in plan
assert plan["ambiguities"]
def test_model_schedule_with_unresolved_requirement_never_generates_successful_draft(schedule_service):
service, catalog, user_root = schedule_service
context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7"))
output = structured_schedule()
output["ambiguities"] = ["不能完成要求的主驾座椅绝对位置控制。"]
plan = normalize_scene_output(output, context, dynamic=True)
assert "metadata" not in plan
with pytest.raises(ValueError, match="未解决"):
service.preview_native_can(
"构建一个流光动态模式", provider="local", conversation_id="conv-schedule-ambiguous",
execution_mode="hardware", vehicle_id="denza_n7", assistant_plan=plan,
)
assert not list(user_root.rglob("*.yaml"))
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
@pytest.mark.parametrize(("seat", "allowed_targets"), [
("主驾", {"driver", "front_left"}), ("副驾", {"passenger", "front_right"}),
])
def test_single_seat_dynamic_execution_cannot_expand_to_both_seats(schedule_service, seat, allowed_targets):
service, catalog, _ = schedule_service
_, saved = create_saved(service, catalog, "构建一个主驾和副驾的座椅动态模式")
result = service.process(
f"只让{seat}进入{saved['name']}", provider="local", conversation_id="conv-schedule-single-seat",
execution_mode="dry_run", vehicle_id="denza_n7",
)
if result["status"] == "succeeded":
assert result["orchestration_route"] == "mode_schedule_execute"
execution = result["schedule_execution"]
for phase in execution["phase_results"]:
actions = phase["result"]["intent_ir"]["spec"]["actions"]
seat_targets = {action["target"].get("instance") or action["target"].get("zone") for action in actions if action["domain"] == "seat"}
assert seat_targets and seat_targets <= allowed_targets
else:
assert result["status"] in {"validation_failed", "needs_clarification", "needs_confirmation", "failed"}
assert not result.get("schedule_execution", {}).get("phase_results")
assert result.get("hardware_execution_allowed") is not True
def start_waiting_schedule(service):
"""Run only a no-device test callback, holding until the tested stop request."""
calls = []
entered = Event()
schedule = ModeSchedule.from_dict({
"schema": "cognitive-agent-mode-schedule/v1", "repeat_count": 1,
"phases": [
{"id": phase_id, "name": phase_id, "duration_ms": 900000,
"operations": [{"domain": "climate", "capability": "climate.set_temperature",
"target_scope": "vehicle", "operation": "set",
"parameters": {"temperature_c": temperature}, "reason": "test only"}]}
for phase_id, temperature in (("first", 28), ("second", 24))
],
})
def execute(phase):
calls.append(phase.id)
entered.set()
return {"status": "succeeded"}
service.mode_schedule_runner.start("denza_n7", schedule, execute, session_id="conv-stop-status")
assert entered.wait(2)
assert service.mode_schedule_runner.status("denza_n7")["status"] == "running"
return calls
def assistant_control(catalog, message, execute):
client = MagicMock()
client.available = False
assistant = CodexAssistantService(client)
snapshot = catalog.snapshot(vehicle_id="denza_n7")
events = []
response = assistant.stream(
message, conversation_id="conv-stop-status", principal="schedule-status-test", history=[],
emit=lambda event: events.append(event) or True,
execute_control=execute, capability_snapshot=snapshot,
capability_context=catalog.compact_context(snapshot),
scene_capability_context=catalog.scene_composition_context(snapshot),
control_terms=catalog.control_terms(snapshot),
)
client.start_thread.assert_not_called()
client.start_structured_turn.assert_not_called()
client.start_turn.assert_not_called()
return response, events
def test_dry_run_stop_does_not_cancel_a_running_vehicle_schedule(schedule_service):
service, _, _ = schedule_service
calls = start_waiting_schedule(service)
result = service.process(
"停止动态模式", provider="local", conversation_id="conv-stop-status",
execution_mode="dry_run", vehicle_id="denza_n7",
)
assert result["status"] == "succeeded"
assert result["schedule_execution"]["status"] == "simulated"
assert result["hardware_execution_allowed"] is False
assert service.mode_schedule_runner.status("denza_n7")["status"] == "running"
assert calls == ["first"]
persisted = json.loads((service.artifact_root / result["request_id"] / "result.json").read_text(encoding="utf-8"))
assert persisted["orchestration_route"] == "mode_schedule_stop"
assert persisted["trace_events"] == []
def test_hardware_stop_cancels_future_stages_and_persists_the_result(schedule_service):
service, _, _ = schedule_service
calls = start_waiting_schedule(service)
result = service.process(
"停止动态模式", provider="local", conversation_id="conv-stop-status",
execution_mode="hardware", vehicle_id="denza_n7",
)
assert result["status"] == "succeeded"
assert result["schedule_execution"]["status"] == "cancelled"
assert result["schedule_execution"]["completed"] is False
assert service.mode_schedule_runner.status("denza_n7")["status"] == "cancelled"
assert service.mode_schedule_runner.close(timeout=1)
assert calls == ["first"]
persisted = json.loads((service.artifact_root / result["request_id"] / "result.json").read_text(encoding="utf-8"))
assert persisted["orchestration_route"] == "mode_schedule_stop"
assert persisted["schedule_execution"]["status"] == "cancelled"
assert persisted["trace_events"] == []
@pytest.mark.parametrize(("execution_mode", "action_state", "runner_state"), [
("hardware", "cancelled", "cancelled"), ("dry_run", "simulated", "running"),
])
def test_assistant_stop_is_zero_model_success_and_preserves_execution_policy(
schedule_service, execution_mode, action_state, runner_state,
):
service, catalog, _ = schedule_service
calls = start_waiting_schedule(service)
def execute(message, progress):
return service.process(
message, provider="local", conversation_id="conv-stop-status",
execution_mode=execution_mode, vehicle_id="denza_n7",
)
response, events = assistant_control(catalog, "停止动态模式", execute)
assert response["success"] is True, response
assert response["route"] == "vehicle_control"
action = next(event for event in events if event["type"] == "action.completed")
assert action["success"] is True
assert action["state"] == action_state
assert action["payload"]["orchestration_route"] == "mode_schedule_stop"
assert service.mode_schedule_runner.status("denza_n7")["status"] == runner_state
assert calls == ["first"]
assert "已完成" not in response["answer"]
assert "未改变正在运行" in response["answer"] if execution_mode == "dry_run" else "已停止" in response["answer"]
def test_assistant_running_schedule_is_accepted_without_claiming_all_phases_completed(schedule_service):
service, catalog, _ = schedule_service
preview, profile = create_saved(service, catalog)
start_waiting_schedule(service)
state = service.mode_schedule_runner.status("denza_n7")
state["phase_results"] = [{"command_frames": ["sensitive-can-frame"]}]
def execute(message, progress):
return {
"status": "running", "execution_mode": "hardware", "hardware_execution_allowed": True,
"orchestration_route": "mode_schedule_execute", "schedule_execution": state,
"mode_profile": profile, "matched_modes": [profile], "schedule": preview["schedule"],
}
response, events = assistant_control(catalog, f"进入{profile['name']}", execute)
assert response["success"] is True, response
action = next(event for event in events if event["type"] == "action.completed")
assert action["success"] is True
assert action["state"] == "running"
assert action["payload"]["status"] == "running"
assert action["payload"]["schedule_execution"]["completed"] is False
assert "phase_results" not in action["payload"]["schedule_execution"]
assert "sensitive-can-frame" not in json.dumps(events, ensure_ascii=False)
assert "尚未完成所有阶段" in action["display_text"]
assert "尚未完成所有阶段" in response["answer"]
completed = next(event for event in events if event["type"] == "work.completed")
assert completed["state"] == "running"
assert "已完成" not in completed["display_text"]
def test_public_schedule_status_exposes_only_lifecycle_and_redacts_raw_error():
state = {
"run_id": "run-public", "vehicle_id": "denza_n7", "status": "failed",
"phase_index": 1, "repeat_index": 0, "completed": False,
"error": "secret-token /private/path/driver.dll CAN payload 123456",
"phase_results": [{"command_plan": {"data_hex": "deadbeef"}}],
"raw_error": {"token": "secret-token"}, "command_plans": {"internal": "data"},
"phase_count": {"nested": "unexpected"},
}
public = public_schedule_status(state)
assert public["status"] == "failed"
assert public["phase_index"] == 1
assert public["completed"] is False
assert public["error"] and public["error"] != state["error"]
assert set(public) == {"run_id", "vehicle_id", "status", "phase_index", "repeat_index", "completed", "error"}
for sensitive in ("secret-token", "driver.dll", "deadbeef", "123456"):
assert sensitive not in json.dumps(public, ensure_ascii=False)
@pytest.mark.parametrize("message", ["外循环通风模式", "内循环模式"])
def test_hvac_circulation_state_is_not_a_temporal_schedule(message):
assert needs_mode_schedule(message) is False
@@ -0,0 +1,278 @@
"""Natural-language edits cannot flatten or silently broaden dynamic phases."""
from __future__ import annotations
import hashlib
from copy import deepcopy
from pathlib import Path
from unittest.mock import patch
import pytest
from cognitive_agent.application.mode_profile import ModeProfileRegistry
from cognitive_agent.application.mode_schedule import ModeSchedule
from cognitive_agent.application.mode_schedule_edit import edit_schedule
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
@pytest.fixture
def service(tmp_path):
with patch.dict(
"os.environ",
{
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false",
},
):
instance = SeatDemoService(REPOSITORY_ROOT)
instance.artifact_root = tmp_path / "runs"
instance.mode_profiles = ModeProfileRegistry(
REPOSITORY_ROOT / "config" / "modes",
user_root=tmp_path / "user-modes",
)
try:
with patch.object(
instance.hardware_execution,
"reserve",
side_effect=AssertionError("schedule edit reached hardware"),
):
yield instance
finally:
instance.close()
def _build(service):
template = service.scene_templates.get_template("boarding-warmup")
result = service.preview_native_can(
"构建一个上车预热模式",
provider="local",
conversation_id="conv-schedule-edit",
execution_mode="hardware",
vehicle_id="denza_n7",
assistant_plan=template.plan(),
)
assert result["status"] == "succeeded", result.get("validation")
return result
def _edit(service, built, change):
draft = built["mode_profile_draft"]
reference = {key: draft[key] for key in ("name", "source_request_id", "vehicle_id")}
return service.preview_native_can(
f"把上车预热模式的{change}",
provider="local",
conversation_id="conv-schedule-edit",
execution_mode="hardware",
vehicle_id="denza_n7",
mode_profile_draft_context=reference,
)
def _parameters(schedule, index, capability, target="vehicle"):
return next(
op["parameters"]
for op in schedule["phases"][index]["operations"]
if op["capability"] == capability and op["target_scope"] == target
)
@pytest.mark.parametrize(
"change",
(
"第二阶段空调温度调整到26度",
"第2个阶段的空调温度变成26度",
"舒适恒温阶段空调温度改为26度",
),
)
def test_draft_temperature_edit_preserves_first_phase_and_timing(service, change):
built = _build(service)
original = deepcopy(built["schedule"])
revised = _edit(service, built, change)
assert revised["status"] == "succeeded", revised.get("mode_creation_failure")
expected = deepcopy(original)
_parameters(expected, 1, "climate.set_temperature")["temperature_c"] = 26
assert revised["schedule"] == expected
assert built["schedule"] == original
assert revised["mode_profile_draft"]["schedule"] == expected
assert revised["mode_profile_draft"]["draft_revision"] == 2
assert revised["mode_profile_draft"]["revises_source_request_id"] == built["request_id"]
assert revised["execution_mode"] == "dry_run"
assert revised["hardware_execution_allowed"] is False
@pytest.mark.parametrize(
("change", "expected_hold", "expected_repeat"),
(
("第二阶段持续时间调整为3分钟", 180000, 1),
("第二阶段保持30秒", 30000, 1),
("第二阶段时长改成2.5分钟", 150000, 1),
("循环次数改成3次", 300000, 3),
("重复两次", 300000, 2),
),
)
def test_draft_time_and_repeat_edits_keep_operations(
service, change, expected_hold, expected_repeat
):
built = _build(service)
revised = _edit(service, built, change)
assert revised["status"] == "succeeded", revised.get("mode_creation_failure")
assert revised["schedule"]["phases"][1]["duration_ms"] == expected_hold
assert revised["schedule"]["repeat_count"] == expected_repeat
for index in (0, 1):
assert (
revised["schedule"]["phases"][index]["operations"]
== built["schedule"]["phases"][index]["operations"]
)
def test_all_phases_temperature_edit_is_explicit_and_preserves_all_other_operations(service):
built = _build(service)
revised = _edit(service, built, "全部阶段空调温度调整到25度")
assert revised["status"] == "succeeded", revised.get("mode_creation_failure")
expected = deepcopy(built["schedule"])
for index in (0, 1):
_parameters(expected, index, "climate.set_temperature")["temperature_c"] = 25
assert revised["schedule"] == expected
@pytest.mark.parametrize(
"change",
(
"空调温度调整到25度",
"第三阶段空调温度调整到25度",
"第二阶段持续16分钟",
"循环21次",
"重复1.5次",
"持续30秒",
"第二阶段主驾靠背调整到90%",
"第二阶段空调温度调整到99度",
"第二阶段打开后备箱",
"第二阶段空调温度调整到26度并打开后备箱",
),
)
def test_ambiguous_unsafe_or_unbounded_edit_leaves_original_draft_unchanged(service, change):
built = _build(service)
original = deepcopy(built["schedule"])
revised = _edit(service, built, change)
assert revised["status"] in {"needs_confirmation", "validation_failed"}
assert not revised.get("mode_profile_draft")
assert revised["hardware_execution_allowed"] is False
source = service._load_mode_profile_draft_reference(
{
key: built["mode_profile_draft"][key]
for key in ("name", "source_request_id", "vehicle_id")
},
conversation_id="conv-schedule-edit",
mode_name="上车预热模式",
vehicle_id="denza_n7",
)[1]
assert source["schedule"] == original
def test_saved_schedule_edit_revalidates_and_persists_timeline_not_flat_actions(service):
built = _build(service)
draft = built["mode_profile_draft"]
saved = service.create_mode_profile_card(
draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"]
)
mode_id = saved["mode_profile"]["id"]
profile = service.mode_profiles.get_profile(mode_id, "denza_n7")
path = Path(profile.source)
before = hashlib.sha256(path.read_bytes()).hexdigest()
rejected = service.preview_native_can(
"把上车预热模式的空调温度调整到26度",
provider="local",
conversation_id="conv-saved-schedule-edit",
execution_mode="hardware",
vehicle_id="denza_n7",
)
assert rejected["status"] == "needs_confirmation"
assert hashlib.sha256(path.read_bytes()).hexdigest() == before
revised = service.preview_native_can(
"把上车预热模式的第二阶段空调温度调整到26度",
provider="local",
conversation_id="conv-saved-schedule-edit",
execution_mode="hardware",
vehicle_id="denza_n7",
)
assert revised["status"] == "succeeded", revised.get("validation")
assert revised["orchestration_route"] == "mode_profile_update"
assert revised["hardware_execution_allowed"] is False
actual = service.mode_profiles.get_profile(mode_id, "denza_n7").schedule.to_dict()
expected = deepcopy(built["schedule"])
_parameters(expected, 1, "climate.set_temperature")["temperature_c"] = 26
assert actual == expected
assert revised["schedule"] == expected
assert revised["mode_profile"]["behavior"] == "cyclic"
def test_combined_phase_action_hold_then_repeat_revision_preserves_timeline(service):
built = _build(service)
revised = _edit(service, built, "第二阶段空调温度调整到26度,持续3分钟")
assert revised["status"] == "succeeded", revised.get("mode_creation_failure")
repeated = _edit(service, revised, "重复两次")
assert repeated["status"] == "succeeded", repeated.get("mode_creation_failure")
expected = deepcopy(built["schedule"])
_parameters(expected, 1, "climate.set_temperature")["temperature_c"] = 26
expected["phases"][1]["duration_ms"] = 180000
expected["repeat_count"] = 2
assert repeated["schedule"] == expected
assert repeated["mode_profile_draft"]["draft_revision"] == 3
draft = repeated["mode_profile_draft"]
saved = service.create_mode_profile_card(
draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"]
)
assert (
service.mode_profiles.get_profile(
saved["mode_profile"]["id"], "denza_n7"
).schedule.to_dict()
== expected
)
def test_all_phases_is_not_mistaken_for_all_vehicle_zones(service):
source = service.scene_templates.get_template("boarding-warmup").plan()["schedule"]
for phase in source["phases"]:
for operation in phase["operations"]:
if operation["capability"] == "climate.set_temperature":
operation["target_scope"] = "passenger"
schedule = ModeSchedule.from_dict(source)
updated = edit_schedule(
schedule,
"全部阶段空调温度调整到25度",
parse_operations=lambda _: [
{
"domain": "climate",
"capability": "climate.set_temperature",
"target_scope": "vehicle",
"operation": "set",
"parameters": {"temperature_c": 25},
}
],
target_is_explicit=service._mode_update_target_is_explicit,
)
for index in (0, 1):
assert _parameters(updated.to_dict(), index, "climate.set_temperature", "passenger") == {
"temperature_c": 25
}
assert not any(
op["capability"] == "climate.set_temperature" and op["target_scope"] == "vehicle"
for op in updated.phases[index].operations
)
def test_phase_motor_duration_cannot_become_a_hold_duration(service):
built = _build(service)
result = _edit(service, built, "第二阶段副驾靠背向后移动3s")
assert result["status"] in {"needs_confirmation", "validation_failed"}
assert not result.get("mode_profile_draft")
def test_stage_selector_does_not_authorize_delayed_action_semantics(service):
built = _build(service)
result = _edit(service, built, "第二阶段30秒后空调温度调整到26度")
assert result["status"] == "needs_confirmation"
assert not result.get("mode_profile_draft")
@@ -273,7 +273,8 @@ def test_name_only_nap_request_reuses_the_verified_repository_profile(
draft = result["mode_profile_draft"] draft = result["mode_profile_draft"]
assert isinstance(draft, dict) assert isinstance(draft, dict)
assert draft["name"] == "小憩模式" assert draft["name"] == "小憩模式"
assert draft["operation_count"] == len(preset.operations) == 9 assert draft["operation_count"] == 9
assert len(preset.operations) == 10 # The registry retains both optional seat variants.
assert set(draft["domains"]) == {"seat", "climate", "roof", "ambient_light"} assert set(draft["domains"]) == {"seat", "climate", "roof", "ambient_light"}
assert draft["existing_mode_id"] == "nap" assert draft["existing_mode_id"] == "nap"
assert isinstance(draft["existing_mode_revision"], str) assert isinstance(draft["existing_mode_revision"], str)
@@ -283,6 +284,7 @@ def test_name_only_nap_request_reuses_the_verified_repository_profile(
actions = _intent_actions(result) actions = _intent_actions(result)
assert {action["capability"] for action in actions} == { assert {action["capability"] for action in actions} == {
operation.capability for operation in preset.operations operation.capability for operation in preset.operations
if operation.domain != "seat" or operation.target_scope in {"passenger", "front_right"}
} == EXPECTED_NAP_CAPABILITIES } == EXPECTED_NAP_CAPABILITIES
expected_targets_and_parameters = { expected_targets_and_parameters = {
operation.capability: ( operation.capability: (
@@ -292,6 +294,7 @@ def test_name_only_nap_request_reuses_the_verified_repository_profile(
operation.parameters, operation.parameters,
) )
for operation in preset.operations for operation in preset.operations
if operation.domain != "seat" or operation.target_scope in {"passenger", "front_right"}
} }
actual_targets_and_parameters = { actual_targets_and_parameters = {
action["capability"]: ( action["capability"]: (
@@ -0,0 +1,347 @@
"""Seat-aware built-in defaults must remain safe and preserve saved user intent."""
from __future__ import annotations
import hashlib
from collections.abc import Iterator
from pathlib import Path
from unittest.mock import patch
import pytest
from cognitive_agent.application.mode_profile import ModeProfileRegistry
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
COMFORT_TEMPLATE_IDS = (
"reading",
"cinema",
"camping",
"quiet-night",
"reception",
"romantic",
"winter-warmth",
"summer-cooling",
"long-trip-comfort",
"relaxation",
"seat-comfort",
)
DRIVER_COMFORT_CAPABILITIES = {
"seat.set_driver_ventilation_native",
"seat.set_driver_heating_native",
"seat.set_driver_massage_native",
}
@pytest.fixture
def service(tmp_path: Path) -> Iterator[SeatDemoService]:
with patch.dict(
"os.environ",
{
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false",
},
):
instance = SeatDemoService(REPOSITORY_ROOT)
instance.artifact_root = tmp_path / "runs"
instance.mode_profiles = ModeProfileRegistry(
REPOSITORY_ROOT / "config" / "modes",
user_root=tmp_path / "user-modes",
)
try:
with patch.object(
instance.hardware_execution,
"reserve",
side_effect=AssertionError("creating or previewing a mode reached real hardware"),
):
yield instance
finally:
instance.close()
def _preview(service: SeatDemoService, instruction: str, *, plan=None) -> dict:
result = service.preview_native_can(
instruction,
provider="local",
conversation_id="conv-registered-seat-coverage",
execution_mode="hardware",
vehicle_id="denza_n7",
assistant_plan=plan,
)
assert result["status"] == "succeeded", result.get("validation")
assert result["execution_mode"] == "dry_run"
assert result["hardware_execution_allowed"] is False
return result
def _action_map(result: dict) -> dict:
return {
(
action["capability"],
action["target"].get("instance") or action["target"].get("zone"),
): action["parameters"]
for action in result["intent_ir"]["spec"]["actions"]
}
@pytest.mark.parametrize("template_id", COMFORT_TEMPLATE_IDS)
@pytest.mark.parametrize(("seat_prefix", "targets"), (
("", {"passenger"}), ("主驾", {"driver"}),
("副驾", {"passenger"}), ("主驾和副驾", {"driver", "passenger"}),
))
def test_comfort_templates_select_only_requested_seat_without_mutating_seat_seeds(
service: SeatDemoService,
template_id: str,
seat_prefix: str,
targets: set[str],
) -> None:
template = service.scene_templates.get_template(template_id)
original_revision = template.revision
result = _preview(service, f"构建一个{seat_prefix}{template.name}", plan=template.plan())
actions = _action_map(result)
seat_keys = {key for key in actions if key[0].startswith("seat.")}
assert {target for _, target in seat_keys} == targets
assert {
operation.target_scope for operation in template.operations if operation.domain == "seat"
} == {"driver", "passenger"}
assert all(
operation.capability in DRIVER_COMFORT_CAPABILITIES
for operation in template.operations
if operation.domain == "seat" and operation.target_scope == "driver"
), "driver positional or timed movement must not enter static comfort defaults"
assert not any(
"massage" in capability for capability, target in seat_keys if target == "passenger"
)
assert actions == {
(operation.capability, operation.target_scope): operation.parameters
for operation in template.operations
if operation.domain != "seat" or operation.target_scope in targets
}
assert result["mode_profile_draft"]["generation_source"] == "scene_template_copy"
assert service.scene_templates.get_template(template_id).revision == original_revision
@pytest.mark.parametrize(
("mode_id", "mode_name"),
(
("office", "办公模式"),
("nap", "小憩模式"),
("meditation_space", "冥想空间模式"),
("leisure_lounger", "休闲躺椅模式"),
),
)
@pytest.mark.parametrize(("seat_prefix", "targets"), (
("", {"passenger"}), ("主驾", {"driver"}),
("副驾", {"passenger"}), ("主驾和副驾", {"driver", "passenger"}),
))
def test_registered_cabin_modes_select_only_requested_seat_on_creation(
service: SeatDemoService,
mode_id: str,
mode_name: str,
seat_prefix: str,
targets: set[str],
) -> None:
result = _preview(service, f"构建一个{seat_prefix}{mode_name}")
actions = _action_map(result)
assert {target for capability, target in actions if capability.startswith("seat.")} == targets
if "passenger" in targets:
assert ("seat.set_backrest_recline", "passenger") in actions
draft = result["mode_profile_draft"]
if seat_prefix:
assert "existing_mode_id" not in draft, "a selected-seat copy must not overwrite the original preset"
else:
assert draft["existing_mode_id"] == mode_id
assert draft["generation_source"] == "registered_mode_profile"
profile = service.mode_profiles.get_profile(mode_id, "denza_n7")
assert actions == {
(operation.capability, operation.target_scope): operation.parameters
for operation in profile.operations
if operation.domain != "seat" or operation.target_scope in targets
}
@pytest.mark.parametrize("template_id", ("fresh-air", "rain-defrost"))
def test_air_quality_and_visibility_templates_do_not_invent_seat_actions(
service: SeatDemoService,
template_id: str,
) -> None:
template = service.scene_templates.get_template(template_id)
result = _preview(service, f"构建一个{template.name}", plan=template.plan())
assert not any(capability.startswith("seat.") for capability, _ in _action_map(result))
@pytest.mark.parametrize(
"template_id",
(
"long-trip-comfort",
"summer-cooling",
"winter-warmth",
"reception",
"quiet-night",
"romantic",
),
)
def test_non_posture_templates_keep_both_seat_positions_unchanged(
service: SeatDemoService,
template_id: str,
) -> None:
template = service.scene_templates.get_template(template_id)
assert all(
"position_pct" not in operation.parameters and "duration_ms" not in operation.parameters
for operation in template.operations
if operation.domain == "seat"
)
def test_native_driver_and_passenger_thermal_levels_are_not_confused(
service: SeatDemoService,
) -> None:
for template_id in (
"reading",
"camping",
"quiet-night",
"reception",
"romantic",
"long-trip-comfort",
"seat-comfort",
):
template = service.scene_templates.get_template(template_id)
params = {(op.capability, op.target_scope): op.parameters for op in template.operations}
assert params[("seat.set_driver_ventilation_native", "driver")] == {
"state": "on",
"level": 2,
}
assert params[("seat.set_ventilation", "passenger")] == {"state": "on", "level": 1}
def test_upgraded_template_does_not_silently_add_seats_to_a_saved_custom_mode(
service: SeatDemoService,
tmp_path: Path,
) -> None:
# Simulate a previously saved user preference with the same name as a template.
original = _preview(service, "构建一个观影模式,空调温度调整到25度")
draft = original["mode_profile_draft"]
assert len(_action_map(original)) == 1
saved = service.create_mode_profile_card(
draft["name"],
draft["instruction"],
draft["source_request_id"],
draft["vehicle_id"],
)
assert saved["status"] == "succeeded"
paths = list((tmp_path / "user-modes").rglob("*.yaml"))
hashes = {path: hashlib.sha256(path.read_bytes()).hexdigest() for path in paths}
repeated = _preview(service, "构建一个观影模式")
assert repeated["mode_profile_draft"]["generation_source"] == "registered_mode_profile"
assert _action_map(repeated) == _action_map(original)
assert {path: hashlib.sha256(path.read_bytes()).hexdigest() for path in paths} == hashes
assert len(service.scene_templates.get_template("cinema").operations) == 11
def test_explicit_seat_only_requirement_is_not_expanded_to_other_seat_defaults(
service: SeatDemoService,
) -> None:
result = _preview(service, "构建一个阅读模式,副驾座椅通风关闭")
assert _action_map(result) == {("seat.set_ventilation", "passenger"): {"state": "off"}}
assert result["mode_profile_draft"]["generation_source"] == "explicit_scene_requirements"
@pytest.mark.parametrize("mode_id", ("office", "nap", "meditation_space", "leisure_lounger"))
@pytest.mark.parametrize(("seat", "spoken_seat"), (("driver", "主驾"), ("passenger", "副驾驶")))
def test_explicit_single_seat_invocation_selects_without_mutating_cabin_mode(
service: SeatDemoService,
mode_id: str,
seat: str,
spoken_seat: str,
) -> None:
profile = service.mode_profiles.get_profile(mode_id, "denza_n7")
original_revision = service.mode_profiles.profile_revision(profile)
expected = {
(operation.capability, operation.target_scope): operation.parameters
for operation in profile.operations
if operation.domain != "seat" or operation.target_scope == seat
}
result = service.preview_native_can(
f"请让{spoken_seat}进入{profile.name}",
provider="local",
conversation_id="conv-explicit-seat-mode-selection",
execution_mode="dry_run",
vehicle_id="denza_n7",
)
assert result["status"] == "succeeded", result.get("validation")
assert _action_map(result) == expected
assert (
service.mode_profiles.profile_revision(
service.mode_profiles.get_profile(mode_id, "denza_n7"),
)
== original_revision
)
assert any(
operation.target_scope != seat
for operation in service.mode_profiles.get_profile(mode_id, "denza_n7").operations
if operation.domain == "seat"
), "single-seat execution must not edit the persisted dual-seat profile"
@pytest.mark.parametrize(
("mode_id", "spoken_seat"),
[
(mode_id, seat)
for mode_id in ("zero_gravity", "easy_entry_exit", "rocking_horse", "office")
for seat in ("主驾", "后排", "所有座椅")
if (mode_id, seat) != ("office", "主驾")
],
)
def test_mode_invocation_still_rejects_genuinely_unsupported_seat_scopes(
service: SeatDemoService,
mode_id: str,
spoken_seat: str,
) -> None:
profile = service.mode_profiles.get_profile(mode_id, "denza_n7")
result = service.preview_native_can(
f"请让{spoken_seat}进入{profile.name}",
provider="local",
conversation_id="conv-explicit-seat-mode-rejection",
execution_mode="dry_run",
vehicle_id="denza_n7",
)
assert result["status"] in {"no_action", "needs_confirmation"}
assert _action_map(result) == {}
assert any(
issue.get("reason") == "mode_target_scope_mismatch"
for issue in result["intent_ir"]["spec"].get("ambiguities", [])
)
@pytest.mark.parametrize(
"message",
("创建一个主驾办公模式", "修改办公模式,只要主驾", "主驾办公模式"),
)
def test_mode_seat_selection_requires_an_explicit_invocation_not_configuration(
service: SeatDemoService,
message: str,
) -> None:
profile = service.mode_profiles.get_profile("office", "denza_n7")
assert service._explicit_mode_seat_selection(profile, message) is None
@pytest.mark.parametrize("mode_id", ("office", "nap", "meditation_space", "leisure_lounger"))
def test_unscoped_mode_invocation_keeps_registered_native_driver_actions(
service: SeatDemoService,
mode_id: str,
) -> None:
profile = service.mode_profiles.get_profile(mode_id, "denza_n7")
result = service.preview_native_can(
f"进入{profile.name}",
provider="local",
conversation_id="conv-mode-native-seat-preserved",
execution_mode="dry_run",
vehicle_id="denza_n7",
)
assert result["status"] == "succeeded"
assert _action_map(result) == {
(operation.capability, operation.target_scope): operation.parameters
for operation in profile.operations
}
@@ -87,7 +87,10 @@ def test_explicit_scene_seat_scope_rejects_cross_seat_plan_atomically(scene_serv
assert result.get("command_plans") == {} assert result.get("command_plans") == {}
intent = result.get("intent_ir") or {} intent = result.get("intent_ir") or {}
assert (intent.get("spec") or {}).get("actions", []) == [] assert (intent.get("spec") or {}).get("actions", []) == []
assert any("座位范围" in item["message"] for item in result["validation"]) assert any(
"座位范围" in item["message"] or item.get("code") == "MODE_SEAT_SCOPE_UNAVAILABLE"
for item in result["validation"]
)
@pytest.mark.parametrize(("message", "scopes"), ( @pytest.mark.parametrize(("message", "scopes"), (
@@ -96,9 +99,9 @@ def test_explicit_scene_seat_scope_rejects_cross_seat_plan_atomically(scene_serv
("创建一个仅主驾舒适模式", ("driver",)), ("创建一个仅主驾舒适模式", ("driver",)),
("构建一个主驾和副驾座椅舒适模式", ("driver", "passenger")), ("构建一个主驾和副驾座椅舒适模式", ("driver", "passenger")),
("构建一个主副驾座椅舒适模式", ("driver", "passenger")), ("构建一个主副驾座椅舒适模式", ("driver", "passenger")),
("构建一个云端座椅舒适模式", ("driver", "passenger")), ("构建一个云端座椅舒适模式", ("passenger",)),
("构建一个副驾空调与座椅舒适模式", ("driver", "passenger")), ("构建一个副驾空调与座椅舒适模式", ("passenger",)),
("构建一个座椅舒适与副驾阅读灯模式", ("driver", "passenger")), ("构建一个座椅舒适与副驾阅读灯模式", ("passenger",)),
("构建一个主驾座椅与副驾空调舒适模式", ("driver",)), ("构建一个主驾座椅与副驾空调舒适模式", ("driver",)),
("构建一个主驾阅读灯与副驾座椅舒适模式", ("passenger",)), ("构建一个主驾阅读灯与副驾座椅舒适模式", ("passenger",)),
)) ))
@@ -120,7 +123,6 @@ def test_scene_scope_preserves_supported_targets_without_borrowing_other_device_
@pytest.mark.parametrize("message", ( @pytest.mark.parametrize("message", (
"构建一个主驾舒适模式,副驾不要动", "构建一个主驾舒适模式,副驾不要动",
"构建一个主驾舒适模式,不要动副驾", "构建一个主驾舒适模式,不要动副驾",
"构建一个舒展模式,座椅靠背调整到45%",
)) ))
def test_explicit_atomic_requirements_do_not_receive_scene_scope_relaxation( def test_explicit_atomic_requirements_do_not_receive_scene_scope_relaxation(
scene_service, message, scene_service, message,
@@ -131,6 +133,19 @@ def test_explicit_atomic_requirements_do_not_receive_scene_scope_relaxation(
assert result["hardware_execution_allowed"] is False assert result["hardware_execution_allowed"] is False
def test_unqualified_explicit_seat_requirement_defaults_to_passenger_without_model_additions(scene_service):
result = _preview(scene_service, "构建一个舒展模式,座椅靠背调整到45%", execution_mode="dry_run")
assert result["status"] == "succeeded", result.get("validation")
assert result["execution_mode"] == "dry_run"
assert result["hardware_execution_allowed"] is False
assert result["mode_profile_draft"]["operation_count"] == 1
actions = result["intent_ir"]["spec"]["actions"]
assert len(actions) == 1
assert actions[0]["capability"] == "seat.set_backrest_recline"
assert actions[0]["target"]["instance"] == "passenger"
assert actions[0]["parameters"] == {"position_pct": 45}
def test_explicit_atomic_requirements_override_label_without_adding_model_actions(scene_service): def test_explicit_atomic_requirements_override_label_without_adding_model_actions(scene_service):
result = _preview(scene_service, "构建一个主驾舒适模式,副驾靠背调整到45%") result = _preview(scene_service, "构建一个主驾舒适模式,副驾靠背调整到45%")
assert result["status"] == "succeeded" assert result["status"] == "succeeded"
+36 -14
View File
@@ -144,7 +144,7 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run(
templates = snapshot["scene_templates"] templates = snapshot["scene_templates"]
assert isinstance(templates, list) assert isinstance(templates, list)
assert len(templates) == 13 assert len(templates) == 15
assert all(template["assistant_callable"] is True for template in templates) assert all(template["assistant_callable"] is True for template in templates)
with patch.object( with patch.object(
@@ -169,11 +169,30 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run(
assert result["orchestration_route"] == "mode_profile_create_preview" assert result["orchestration_route"] == "mode_profile_create_preview"
draft = result["mode_profile_draft"] draft = result["mode_profile_draft"]
assert draft["generation_source"] == "scene_template_copy" assert draft["generation_source"] == "scene_template_copy"
assert draft["operation_count"] == template["operation_count"]
assert draft["source_template_id"] == template["id"] assert draft["source_template_id"] == template["id"]
assert draft["source_template_revision"] == template["template_revision"] assert draft["source_template_revision"] == template["template_revision"]
assert "existing_mode_id" not in draft assert "existing_mode_id" not in draft
expected_requests = plan["requests"]
if plan.get("schedule"):
expected_schedule = deepcopy(plan["schedule"])
for phase in expected_schedule["phases"]:
phase["operations"] = [
op for op in phase["operations"]
if op["domain"] != "seat" or op["target_scope"] == "passenger"
]
assert draft["schedule"] == expected_schedule
expected_requests = [
{**op, "parameters": [{"name": key, "value": value}
for key, value in op["parameters"].items()]}
for phase in expected_schedule["phases"] for op in phase["operations"]
]
else:
expected_requests = [
request for request in expected_requests
if request["domain"] != "seat" or request["target_scope"] == "passenger"
]
assert draft["operation_count"] == len(expected_requests)
expected = { expected = {
( (
str(request["capability"]), str(request["capability"]),
@@ -187,7 +206,7 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run(
sort_keys=True, sort_keys=True,
), ),
) )
for request in plan["requests"] for request in expected_requests
} }
actual = { actual = {
( (
@@ -311,7 +330,10 @@ def test_purpose_descriptions_use_a_concise_name_and_zero_model_template_route(
assert draft["name"] == expected_name assert draft["name"] == expected_name
assert draft["generation_source"] == "scene_template_copy" assert draft["generation_source"] == "scene_template_copy"
assert draft["source_template_id"] == template_id assert draft["source_template_id"] == template_id
assert draft["operation_count"] == len(template.operations) assert draft["operation_count"] == sum(
operation.domain != "seat" or operation.target_scope == "passenger"
for operation in template.operations
)
assert passed_plans[0]["metadata"]["template_id"] == template_id assert passed_plans[0]["metadata"]["template_id"] == template_id
assert events[0]["engine"] == "n7web-scene-template" assert events[0]["engine"] == "n7web-scene-template"
assert not list(user_root.rglob("*.yaml")) assert not list(user_root.rglob("*.yaml"))
@@ -353,7 +375,7 @@ def test_explicit_settings_on_a_purpose_scene_bypass_template_defaults(
("构建一个长途舒适模式", "长途舒适模式", "long-trip-comfort"), ("构建一个长途舒适模式", "长途舒适模式", "long-trip-comfort"),
), ),
) )
def test_seating_comfort_requests_preview_both_seats_with_no_model_or_hardware_call( def test_seating_comfort_requests_preview_only_passenger_with_no_model_or_hardware_call(
hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path], hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path],
message: str, message: str,
expected_name: str, expected_name: str,
@@ -380,16 +402,13 @@ def test_seating_comfort_requests_preview_both_seats_with_no_model_or_hardware_c
for (capability, target), parameters in _action_map(result).items() for (capability, target), parameters in _action_map(result).items()
if capability.startswith("seat.") if capability.startswith("seat.")
} }
assert {target for _capability, target in seat_actions} == {"driver", "passenger"} assert {target for _capability, target in seat_actions} == {"passenger"}
assert all( assert all(
"driver_axis" not in capability and "driver_position" not in capability "driver_axis" not in capability and "driver_position" not in capability
for capability, _target in seat_actions for capability, _target in seat_actions
) )
assert ("seat.set_driver_massage_native", "passenger") not in seat_actions assert ("seat.set_driver_massage_native", "passenger") not in seat_actions
if template_id in {"seat-comfort", "long-trip-comfort"}: if template_id in {"seat-comfort", "long-trip-comfort"}:
assert seat_actions["seat.set_driver_ventilation_native", "driver"] == {
"state": "on", "level": 2,
}
assert seat_actions["seat.set_ventilation", "passenger"] == { assert seat_actions["seat.set_ventilation", "passenger"] == {
"state": "on", "level": 1, "state": "on", "level": 1,
} }
@@ -422,7 +441,7 @@ def test_unregistered_seat_scene_keeps_both_explicit_targets_through_native_merg
side_effect=AssertionError("scene composition reached hardware execution"), side_effect=AssertionError("scene composition reached hardware execution"),
): ):
result = service.preview_native_can( result = service.preview_native_can(
f"构建一个{name}", provider="local", conversation_id="conv-seat-generated", f"构建一个主驾和副驾的{name}", provider="local", conversation_id="conv-seat-generated",
execution_mode="hardware", vehicle_id="denza_n7", assistant_plan=plan, execution_mode="hardware", vehicle_id="denza_n7", assistant_plan=plan,
) )
assert result["status"] == "succeeded" assert result["status"] == "succeeded"
@@ -437,7 +456,7 @@ def test_unregistered_seat_scene_keeps_both_explicit_targets_through_native_merg
assert not list(user_root.rglob("*.yaml")) assert not list(user_root.rglob("*.yaml"))
def test_explicit_unscoped_seat_instruction_keeps_target_ambiguity_guard( def test_unscoped_seat_instruction_inside_mode_creation_defaults_to_passenger(
hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path], hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path],
) -> None: ) -> None:
service, _catalog, _user_root = hybrid_service service, _catalog, _user_root = hybrid_service
@@ -446,8 +465,11 @@ def test_explicit_unscoped_seat_instruction_keeps_target_ambiguity_guard(
conversation_id="conv-seat-explicit-target", execution_mode="hardware", conversation_id="conv-seat-explicit-target", execution_mode="hardware",
vehicle_id="denza_n7", vehicle_id="denza_n7",
) )
assert result["status"] != "succeeded" assert result["status"] == "succeeded"
assert result.get("mode_profile_draft") is None assert _action_map(result) == {
("seat.set_backrest_recline", "passenger"): {"position_pct": 45},
}
assert result.get("mode_profile_draft")
assert result["hardware_execution_allowed"] is False assert result["hardware_execution_allowed"] is False
@@ -552,7 +574,7 @@ def test_template_copy_can_be_revised_twice_without_losing_sibling_actions(
assert shade_revised["execution_mode"] == final["execution_mode"] == "dry_run" assert shade_revised["execution_mode"] == final["execution_mode"] == "dry_run"
assert shade_revised["hardware_execution_allowed"] is False assert shade_revised["hardware_execution_allowed"] is False
assert final["hardware_execution_allowed"] is False assert final["hardware_execution_allowed"] is False
assert len(built_actions) == len(shade_actions) == len(final_actions) == 7 assert len(built_actions) == len(shade_actions) == len(final_actions) == 10
assert built_actions[shade_key] == {"position_pct": 0} assert built_actions[shade_key] == {"position_pct": 0}
assert shade_actions[shade_key] == final_actions[shade_key] == {"position_pct": 100} assert shade_actions[shade_key] == final_actions[shade_key] == {"position_pct": 100}
assert built_actions[temperature_key] == shade_actions[temperature_key] == {"temperature_c": 24} assert built_actions[temperature_key] == shade_actions[temperature_key] == {"temperature_c": 24}
+435
View File
@@ -0,0 +1,435 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const vm = require("node:vm");
const web = path.resolve(__dirname, "../../apps/web-console");
const assistant = fs.readFileSync(path.join(web, "assistant-chat.js"), "utf8");
const app = fs.readFileSync(path.join(web, "app.js"), "utf8");
function functionSource(source, name) {
const start = source.search(new RegExp(`(?: )?(?:async )?function ${name}\\(`));
assert.ok(start >= 0, `missing production function ${name}`);
const rest = source.slice(start);
const next = rest.slice(1).search(/\n(?: )?(?:async )?function /);
return next < 0 ? rest : rest.slice(0, next + 1);
}
class Element {
constructor(tagName) {
this.tagName = tagName;
this.children = [];
this.dataset = {};
this.attributes = {};
this.listeners = {};
this.className = "";
this.textContent = "";
this.classList = { add: (name) => { this.className += ` ${name}`; } };
}
append(...children) { this.children.push(...children); }
replaceChildren(...children) { this.children = children; }
setAttribute(name, value) { this.attributes[name] = value; }
addEventListener(name, listener) { this.listeners[name] = listener; }
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
querySelectorAll(selector) {
return descendants(this).filter((item) => {
if (selector.startsWith(".")) return item.className.split(/\s+/).includes(selector.slice(1));
const match = selector.match(/^\[data-([\w-]+)(?:="([^"]*)")?\]$/);
if (match) {
const key = match[1].replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase());
return key in item.dataset && (match[2] === undefined || item.dataset[key] === match[2]);
}
return item.tagName === selector;
});
}
}
function descendants(element) {
return element.children.flatMap((item) => typeof item === "object" ? [item, ...descendants(item)] : []);
}
function textOf(element) {
return [element.textContent, ...descendants(element).map((item) => item.textContent)].filter(Boolean).join("\n");
}
function harness() {
const body = new Element("body");
const calls = [];
const document = {
body,
createElement: (tag) => new Element(tag),
createTextNode: (text) => Object.assign(new Element("#text"), { textContent: text }),
querySelectorAll: (selector) => body.querySelectorAll(selector),
};
const context = {
document, window: {}, CSS: { escape: String },
$: (selector, parent = body) => parent.querySelector(selector),
rememberModeDraftContext() {}, supersedeModeDraftCards() {}, followLatest() {},
normalizeModeName: String,
saveModeProfileDraft: (...args) => calls.push({ kind: "save", args }),
tryModeProfileDraft: (...args) => calls.push({ kind: "trial", args }),
modeSecondsLabel: (value) => String(Number(value) / 1000),
LEGACY_DYNAMIC_MODE_IDS: new Set(["rocking_horse", "dynamic_cradle"]),
};
vm.createContext(context);
const constants = app.slice(app.indexOf("const MODE_DOMAIN_LABELS"), app.indexOf("function modeOperationParameters"))
+ app.slice(app.indexOf("const CONFIG_CAPABILITY_LABELS"), app.indexOf("function makeConfigElement"));
const assistantNames = ["modeDraftRows", "modeDraftScheduleView", "appendModeDraftRows", "appendModeDraftState"];
const appNames = ["formatConfigValue", "modeOperationParameters", "formatModeOperationValue", "modeOperationView", "modeTimelineTargetSummary", "modeTimelineStepView", "modeScheduleDurationLabel", "modeScheduleView", "modeExecutionActionViews", "modeScheduleTrialMatches", "modeScheduleExecutionView", "isUserModeProfile", "isDynamicModeProfile", "createModeGalleryCard", "renderModeActionPopover"];
vm.runInContext(constants + assistantNames.map((name) => functionSource(assistant, name)).join("\n")
+ appNames.map((name) => functionSource(app, name)).join("\n"), context);
return { context, body, calls };
}
function operation(capability, parameters, scope = "vehicle") {
return { domain: capability.split(".")[0], capability, target_scope: scope, operation: "set", parameters };
}
function schedule(overrides = {}) {
return {
schema: "cognitive-agent-mode-schedule/v1",
repeat_count: 2,
phases: [
{ id: "warm", name: "快速预热", duration_ms: 60000, operations: [
operation("climate.set_temperature", { temperature_c: 28 }),
operation("seat.set_driver_massage_native", { action: "mode", mode: "relax" }, "driver"),
operation("seat.set_heating", { state: "on", level: 1 }, "passenger"),
] },
{ id: "comfortable", name: "舒适保持", duration_ms: 30000, operations: [
operation("climate.set_temperature", { temperature_c: 24 }),
operation("seat.set_driver_massage_native", { action: "off" }, "driver"),
operation("seat.set_heating", { state: "off" }, "passenger"),
] },
],
...overrides,
};
}
test("assistant keeps phases, relative timing, repeat count, total duration and stop guidance", () => {
const { context } = harness();
const view = context.modeDraftScheduleView({ schedule: schedule() });
assert.equal(view.summary, "2 个阶段 · 重复 2 次 · 总时长 3分");
assert.equal(view.operationCount, 6);
assert.equal(view.phases[0].timing, "+0秒 开始 · 持续 1分");
assert.equal(view.phases[1].timing, "+1分 开始 · 持续 30秒");
assert.match(view.stopHint, /停止动态模式/);
assert.match(view.stopHint, /每轮/);
});
test("driver and passenger scopes and changed device values are not collapsed", () => {
const { context } = harness();
const view = context.modeDraftScheduleView({ schedule: schedule() });
assert.deepEqual(Array.from(view.phases[0].rows, (row) => [row.label, row.value]), [
["空调温度", "28℃"], ["主驾座椅按摩", "放松模式"], ["副驾座椅加热", "开启 · 1档"],
]);
assert.deepEqual(Array.from(view.phases[1].rows, (row) => [row.label, row.value]), [
["空调温度", "24℃"], ["主驾座椅按摩", "关闭"], ["副驾座椅加热", "关闭"],
]);
});
test("phase previews never apply whole-instruction zero-gravity grouping", () => {
const { context } = harness();
const view = context.modeDraftScheduleView({ instruction: "副驾零重力", schedule: schedule({ phases: [{
duration_ms: 1500, operations: [
operation("seat.set_longitudinal_position", { position_pct: 80 }, "passenger"),
operation("seat.set_backrest_recline", { position_pct: 60 }, "passenger"),
],
}] }) });
assert.equal(view.phases[0].rows.length, 2);
assert.equal(view.phases[0].name, "阶段 1");
assert.equal(view.phases[0].timing, "+0秒 开始 · 持续 1.5秒");
});
test("per-zone climate targets are explicit even within the same phase", () => {
const { context } = harness();
const view = context.modeDraftScheduleView({ schedule: schedule({ phases: [{
duration_ms: 2000, operations: [
operation("climate.set_temperature", { temperature_c: 26 }, "front_left"),
operation("climate.set_temperature", { temperature_c: 24 }, "front_right"),
],
}] }) });
assert.deepEqual(Array.from(view.phases[0].rows, (row) => [row.label, row.value]), [
["主驾空调温度", "26℃"], ["副驾空调温度", "24℃"],
]);
});
for (const [name, value] of [
["absent", undefined], ["unknown schema", schedule({ schema: "other" })],
["zero repeat", schedule({ repeat_count: 0 })], ["unbounded repeat", schedule({ repeat_count: 21 })],
["fractional repeat", schedule({ repeat_count: 1.5 })], ["string repeat", schedule({ repeat_count: "2" })],
["empty phases", schedule({ phases: [] })], ["non-array phases", schedule({ phases: {} })],
["zero duration", schedule({ phases: [{ duration_ms: 0, operations: [] }] })],
["negative duration", schedule({ phases: [{ duration_ms: -1, operations: [] }] })],
["invalid duration", schedule({ phases: [{ duration_ms: Infinity, operations: [] }] })],
["missing operations", schedule({ phases: [{ duration_ms: 1000 }] })],
]) {
test(`schedule formatter safely rejects ${name}`, () => {
const { context } = harness();
assert.equal(context.modeDraftScheduleView({ schedule: value }), null);
assert.equal(context.modeScheduleView({ schedule: value }), null);
});
}
test("hover formatter prefers stage operations over an overlapping flat projection", () => {
const { context } = harness();
const mode = { schedule: schedule(), operations: [operation("climate.set_temperature", { temperature_c: 24 })] };
const rows = context.modeExecutionActionViews(mode);
assert.equal(rows.length, 6);
assert.equal(rows[0].phase, "阶段 1:快速预热");
assert.equal(rows[3].phase, "阶段 2:舒适保持");
assert.equal(rows[0].parameters, "目标温度:28 ℃");
assert.equal(rows[3].parameters, "目标温度:24 ℃");
assert.equal(rows[1].target, "主驾");
assert.equal(rows[2].target, "副驾");
assert.equal(context.modeScheduleView(mode).summary, context.modeDraftScheduleView(mode).summary);
assert.equal(context.isDynamicModeProfile({ ...mode, behavior: "static" }), true);
});
test("legacy static modes and seat ModeTimeline retain their original action semantics", () => {
const { context } = harness();
const operations = [operation("climate.set_temperature", { temperature_c: 24 })];
assert.equal(context.modeExecutionActionViews({ operations }).length, 1);
assert.equal(context.isDynamicModeProfile({ operations, behavior: "static" }), false);
const mode = { operations, timeline: {
entry: { targets: { backrest_recline: { position_pct: 40 } }, timeout_ms: 15000 },
phases: [{ name: "抬起", targets: { backrest_recline: { position_pct: 50 } }, duration_ms: 2000 }],
} };
const views = context.modeExecutionActionViews(mode);
assert.equal(views.length, 3);
assert.equal(views[1].action, "进入动态模式初始位置");
assert.match(views[1].parameters, /保护上限 15s/);
assert.equal(views[2].action, "循环阶段 1:抬起");
assert.match(views[2].parameters, /持续 2s/);
assert.equal(context.isDynamicModeProfile(mode), true);
});
test("assistant renders a stage card, preserving opaque save/trial request context", () => {
const { context, body, calls } = harness();
const message = { body: new Element("div"), article: new Element("article") };
body.append(message.body);
const draft = { name: "上车预热模式", source_request_id: "opaque-preview-id", schedule: schedule(), operation_count: 6 };
const flatActions = [];
context.appendModeDraftState(message, draft, flatActions);
const card = message.body.children[0];
assert.equal(card.querySelectorAll(".assistant-mode-schedule-phase").length, 2);
assert.equal(card.querySelectorAll(".assistant-mode-draft-row").length, 6);
assert.equal(card.querySelector(".assistant-mode-draft-badge").textContent, "6 项动作");
assert.match(textOf(card), /28℃/);
assert.match(textOf(card), /24℃/);
assert.match(textOf(card), /重复 2 次 · 总时长 3分/);
assert.match(textOf(card), /停止动态模式/);
card.querySelector(".assistant-mode-draft-save").listeners.click();
card.querySelector(".assistant-mode-draft-trial").listeners.click();
assert.equal(calls[0].kind, "save");
assert.equal(calls[0].args[2], draft);
assert.equal(calls[1].kind, "trial");
assert.equal(calls[1].args[1], draft);
assert.equal(calls[1].args[2], flatActions);
});
test("management schedule details are independently expandable without executing the card", () => {
const { context } = harness();
const card = context.createModeGalleryCard({ id: "custom-warm", name: "上车预热模式", origin: "user", schedule: schedule() });
const details = card.querySelector(".mode-gallery-schedule-details");
assert.ok(details);
assert.equal(details.querySelector("summary").textContent, "查看分阶段执行动作");
assert.equal(details.querySelectorAll("li").length, 2);
assert.equal(details.querySelectorAll("button").length, 0);
assert.match(textOf(details), /快速预热/);
assert.match(textOf(details), /舒适保持/);
assert.match(textOf(details), /主驾 · 设置主驾座椅按摩:动作:切换按摩模式,工作模式:放松/);
assert.match(textOf(card.querySelector(".mode-gallery-card-meta")), /重复 2 次 · 总时长 3分/);
});
test("popover presents stage timings and stop guidance without interpreting text as HTML", () => {
const { context } = harness();
const popover = new Element("aside");
for (const [tag, key] of [["strong", "modeActionTitle"], ["b", "modeActionCount"], ["ol", "modeActionList"]]) {
const item = new Element(tag);
item.dataset[key] = "true";
popover.append(item);
}
context.ensureModeActionPopover = () => popover;
const value = schedule();
value.phases[0].name = '<img src=x onerror="alert(1)">';
context.renderModeActionPopover({ name: "测试动态模式", schedule: value });
assert.equal(popover.querySelectorAll(".mode-chip-action-copy").length, 6);
assert.equal(popover.querySelectorAll(".mode-schedule-popover-phase").length, 6);
assert.match(textOf(popover), /阶段 1:<img/);
assert.match(textOf(popover), /停止动态模式/);
assert.match(textOf(popover), /\+1分 开始 · 持续 30秒/);
assert.equal(popover.querySelectorAll("img").length, 0);
});
test("execution distinguishes accepted schedule, virtual trial and stopped device state", () => {
const { context } = harness();
const running = context.modeScheduleExecutionView({ status: "running", schedule_execution: { status: "running" } });
assert.equal(running.state, "processing");
assert.equal(running.title, "动态时间轴已启动");
assert.doesNotMatch(running.title, /完成/);
const trial = context.modeScheduleExecutionView({ status: "succeeded", schedule_execution: { status: "completed", completed: true, virtual_elapsed_ms: 90000 } });
assert.equal(trial.title, "动态时间轴模拟试运行通过");
assert.match(trial.detail, /未向车辆下发控制/);
const stop = context.modeScheduleExecutionView({ status: "succeeded", schedule_execution: { status: "cancelled" } });
assert.equal(stop.title, "动态时间轴已停止");
assert.match(stop.detail, /不会自动回滚/);
assert.equal(context.modeScheduleExecutionView({ status: "failed", schedule_execution: { status: "failed" } }), null);
});
test("virtual trial verifies temporal structure, not just a flattened action set", () => {
const { context } = harness();
const draft = { schedule: schedule() };
const response = () => ({ schedule: schedule(), schedule_execution: { status: "completed", completed: true, virtual_elapsed_ms: 180000 } });
assert.equal(context.modeScheduleTrialMatches(draft, response()), true);
assert.equal(context.modeScheduleTrialMatches({}, {}), true, "legacy static drafts remain unchanged");
for (const mutate of [
(value) => { value.schedule.repeat_count = 1; },
(value) => { value.schedule.phases.reverse(); },
(value) => { value.schedule.phases[0].duration_ms = 45000; value.schedule.phases[1].duration_ms = 45000; },
(value) => { value.schedule.phases[0].operations[0].parameters.temperature_c = 26; },
(value) => { value.schedule.phases[0].operations[1].target_scope = "passenger"; },
(value) => { value.schedule_execution.virtual_elapsed_ms = 90000; },
(value) => { value.schedule_execution.completed = false; },
(value) => { value.schedule_execution.status = "running"; },
(value) => { delete value.schedule; },
]) {
const value = response();
mutate(value);
assert.equal(context.modeScheduleTrialMatches(draft, value), false);
}
});
function pollHarness(statuses = []) {
const { context } = harness();
const timers = new Map();
const requests = [];
const updates = [];
const modeUpdates = [];
const vehicle = { value: "denza_n7" };
let timerId = 0;
context.activeSchedulePoll = null;
context.AbortController = AbortController;
context.conversation = "conversation-1";
context.conversationId = () => context.conversation;
context.setActionStatus = (_message, copy, state) => updates.push({ copy, state });
const originalQuery = context.$;
context.$ = (selector, parent) => selector === "#vehicleSelect" ? vehicle : originalQuery(selector, parent);
context.window.setTimeout = (callback, delay) => {
assert.equal(delay, 2000);
timers.set(++timerId, callback);
return timerId;
};
context.window.clearTimeout = (id) => timers.delete(id);
context.window.CognitiveAgentConsole = { updateScheduleExecutionStatus: (...args) => modeUpdates.push(args) };
context.window.CognitiveAgentNetwork = { request: async (url, options) => {
requests.push({ url, options });
const next = statuses.shift();
if (next instanceof Error) throw next;
if (typeof next === "function") return next();
return { ok: true, status: 200, json: async () => next };
} };
vm.runInContext(["stopScheduleStatusPoll", "startScheduleStatusPoll"].map((name) => functionSource(assistant, name)).join("\n"), context);
const start = (session = "session-1") => context.startScheduleStatusPoll({
vehicle_id: "denza_n7", mode_profile: { id: "test-mode" }, schedule_execution: { session_id: session },
}, {});
const tick = async () => {
const [id, callback] = timers.entries().next().value || [];
assert.ok(callback, "poll timer must be scheduled");
timers.delete(id);
await callback();
};
return { context, timers, requests, updates, modeUpdates, vehicle, start, tick };
}
test("polls only the active vehicle using GET and updates the original status until completion", async () => {
const h = pollHarness([
{ status: "running", session_id: "session-1", vehicle_id: "denza_n7", phase_index: 1, repeat_index: 0 },
{ status: "completed", session_id: "session-1", completed: true },
]);
h.start();
assert.equal(h.requests.length, 0);
await h.tick();
assert.equal(h.requests.length, 1);
assert.equal(h.requests[0].url, "/api/modes/schedule/status?vehicle_id=denza_n7");
assert.equal(h.requests[0].options.method, "GET");
assert.equal(h.requests[0].options.body, undefined);
assert.match(h.updates[0].copy, /第 1 轮 · 阶段 2/);
assert.equal(h.updates[0].state, "working");
await h.tick();
assert.equal(h.timers.size, 0);
assert.equal(h.context.activeSchedulePoll, null);
assert.match(h.updates[1].copy, /时间轴已结束/);
assert.deepEqual(h.modeUpdates, [["test-mode", "completed"]]);
});
for (const status of ["failed", "cancelled", "stopped", "idle"]) {
test(`terminal ${status} ends polling without a new request or action`, async () => {
const h = pollHarness([{ status, session_id: "session-1", error: "阶段下发失败" }]);
h.start();
await h.tick();
assert.equal(h.requests.length, 1);
assert.equal(h.timers.size, 0);
assert.equal(h.updates[0].state, status === "failed" ? "failed" : "success");
if (status === "failed") assert.match(h.updates[0].copy, /阶段下发失败/);
if (["cancelled", "stopped"].includes(status)) assert.match(h.updates[0].copy, /已停止/);
});
}
test("a transient status failure is visible and retried, not reported as execution failure", async () => {
const h = pollHarness([new Error("offline"), { status: "completed", completed: true }]);
h.start();
await h.tick();
assert.match(h.updates[0].copy, /暂时无法获取时间轴状态/);
assert.equal(h.updates[0].state, "working");
assert.equal(h.timers.size, 1);
await h.tick();
assert.equal(h.timers.size, 0);
});
test("vehicle or conversation changes terminate observation before another request", async () => {
for (const kind of ["vehicle", "conversation"]) {
const h = pollHarness();
h.start();
if (kind === "vehicle") h.vehicle.value = "another-vehicle";
else h.context.conversation = "conversation-2";
await h.tick();
assert.equal(h.requests.length, 0);
assert.equal(h.context.activeSchedulePoll, null);
assert.equal(h.timers.size, 0);
}
});
test("replacement session does not apply a different schedule outcome to the old message", async () => {
const h = pollHarness([{ status: "failed", session_id: "another-session" }]);
h.start();
await h.tick();
assert.match(h.updates[0].copy, /已被新的运行任务替换/);
assert.doesNotMatch(h.updates[0].copy, /执行失败/);
assert.equal(h.timers.size, 0);
assert.equal(h.modeUpdates.length, 0);
});
test("stop aborts an in-flight status read and ignores its late completion", async () => {
let resolve;
const response = new Promise((done) => { resolve = done; });
const h = pollHarness([() => response]);
h.start();
const pending = h.tick();
assert.equal(h.requests.length, 1);
assert.equal(h.timers.size, 0, "an in-flight read cannot overlap another timer");
h.context.stopScheduleStatusPoll();
assert.equal(h.requests[0].options.signal.aborted, true);
resolve({ ok: true, status: 200, json: async () => ({ status: "completed", completed: true }) });
await pending;
assert.equal(h.updates.length, 0);
assert.equal(h.timers.size, 0);
});
test("cleanup is wired to new conversation, vehicle changes and page exit", () => {
assert.match(assistant, /if \(!target\?\.closest\("#newConversation"\)\) return;\s+stopScheduleStatusPoll\(\);/);
assert.match(assistant, /event\.target\?\.id === "vehicleSelect"\) stopScheduleStatusPoll\(\)/);
assert.match(assistant, /window\.addEventListener\("pagehide", stopScheduleStatusPoll\)/);
});
@@ -284,7 +284,7 @@ def test_mode_catalog_projects_bounded_action_details_without_free_form_reasons(
snapshot = _catalog(vehicle_service).snapshot() snapshot = _catalog(vehicle_service).snapshot()
office = next(item for item in snapshot["modes"] if item["id"] == "office") office = next(item for item in snapshot["modes"] if item["id"] == "office")
assert len(office["operations"]) == office["operation_count"] == 10 assert len(office["operations"]) == office["operation_count"] == 12
temperature = next( temperature = next(
item item
for item in office["operations"] for item in office["operations"]
@@ -327,7 +327,7 @@ def test_saved_mode_detail_fast_answer_is_exact_read_only_and_natural(
"遮阳帘", "遮阳帘",
"氛围灯", "氛围灯",
"副驾空调温度设为25℃", "副驾空调温度设为25℃",
"共10项动作", "共12项动作",
"本次仅查询,未下发车辆控制", "本次仅查询,未下发车辆控制",
) )
) )
@@ -358,7 +358,7 @@ def test_common_scene_templates_are_read_only_callable_catalog_entries(
templates = snapshot["scene_templates"] templates = snapshot["scene_templates"]
assert snapshot["source_status"]["scene_templates"] == "ready" assert snapshot["source_status"]["scene_templates"] == "ready"
assert len(templates) == 13 assert len(templates) == 15
assert {item["name"] for item in templates} >= { assert {item["name"] for item in templates} >= {
"观影模式", "观影模式",
"露营模式", "露营模式",
@@ -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-27"' in html assert 'href="/assistant-chat.css?v=codex-assistant-28"' in html
assert 'src="/assistant-chat.js?v=codex-assistant-27"' in html assert 'src="/assistant-chat.js?v=codex-assistant-28"' in html
def test_codex_assistant_assets_are_versioned_together_in_the_service_worker(): 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-v278"' in service_worker assert 'const CACHE_VERSION = "cognitive-agent-pad-v279"' in service_worker
assert '"/assistant-chat.css?v=codex-assistant-27"' in service_worker assert '"/assistant-chat.css?v=codex-assistant-28"' in service_worker
assert '"/assistant-chat.js?v=codex-assistant-27"' in service_worker assert '"/assistant-chat.js?v=codex-assistant-28"' in service_worker
def test_codex_frontend_uses_one_streaming_endpoint_and_agent_work_events(): def test_codex_frontend_uses_one_streaming_endpoint_and_agent_work_events():
@@ -35,10 +35,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-27") < html.index( assert html.index("/assistant-chat.js?v=codex-assistant-28") < html.index(
"/energy-monitor.js?v=energy-monitor-4" "/energy-monitor.js?v=energy-monitor-4"
) )
assert 'const CACHE_VERSION = "cognitive-agent-pad-v278"' in service_worker assert 'const CACHE_VERSION = "cognitive-agent-pad-v279"' in service_worker
def test_energy_monitor_uses_the_fifth_shared_dock_icon_and_starts_closed() -> None: def test_energy_monitor_uses_the_fifth_shared_dock_icon_and_starts_closed() -> None:
+1 -1
View File
@@ -47,7 +47,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-v278"' in service_worker assert 'const CACHE_VERSION = "cognitive-agent-pad-v279"' in service_worker
def test_vehicle_twin_icons_and_nested_overlays_use_semantic_tokens() -> None: def test_vehicle_twin_icons_and_nested_overlays_use_semantic_tokens() -> None:
@@ -0,0 +1,56 @@
from __future__ import annotations
from copy import deepcopy
import pytest
from cognitive_agent.application.mode_creation_seat_policy import (
ModeCreationSeatPolicyError,
creation_seat_scopes,
project_seed_operations,
)
@pytest.mark.parametrize(("text", "expected"), [
("构建一个阅读模式", {"passenger"}),
("构建一个座椅动态模式", {"passenger"}),
("构建一个主驾阅读模式", {"driver"}),
("为驾驶员构建一个舒适模式", {"driver"}),
("构建一个副驾阅读模式", {"passenger"}),
("构建一个副驾驶座椅舒适模式", {"passenger"}),
("构建一个副驾驶席阅读模式", {"passenger"}),
("为副驾驶位构建阅读模式", {"passenger"}),
("构建一个主副驾阅读模式", {"driver", "passenger"}),
("构建一个双座舒适模式", {"driver", "passenger"}),
("构建一个前排两座舒适模式", {"driver", "passenger"}),
("构建一个两个座椅阅读模式", {"driver", "passenger"}),
("构建一个两张座椅阅读模式", {"driver", "passenger"}),
("构建一个两座椅阅读模式", {"driver", "passenger"}),
("构建一个主驾空调舒适模式", {"passenger"}),
("构建一个模式,主驾温度26度", {"passenger"}),
("构建一个副驾座椅舒适模式,主驾空调26度", {"passenger"}),
("构建一个主驾舒适模式,副驾空调26度", {"driver"}),
("构建一个阅读模式,不要动主驾", {"passenger"}),
("构建一个主驾阅读模式,副驾不要动", {"driver"}),
("构建一个模式,只联动副驾,不要联动主驾", {"passenger"}),
("构建一个后排座椅舒适模式", {"unsupported"}),
("构建一个未知座位模式", {"unsupported"}),
("构建一个任意座位模式", {"unsupported"}),
])
def test_creation_seat_selection(text, expected):
assert creation_seat_scopes(text) == expected
def test_seed_projection_is_non_mutating_and_does_not_borrow_capabilities():
seed = [
{"domain": "seat", "capability": "seat.set_driver_ventilation_native", "target_scope": "driver", "parameters": {"level": 2}},
{"domain": "seat", "capability": "seat.set_backrest_recline", "target_scope": "passenger", "parameters": {"position_pct": 45}},
{"domain": "climate", "capability": "climate.set_temperature", "target_scope": "passenger", "parameters": {"temperature_c": 24}},
]
original = deepcopy(seed)
projected = project_seed_operations(seed, frozenset({"driver"}))
assert projected == [seed[0], seed[2]]
projected[0]["parameters"]["level"] = 1
assert seed == original
with pytest.raises(ModeCreationSeatPolicyError):
project_seed_operations(seed[1:], frozenset({"driver"}))
@@ -0,0 +1,186 @@
from __future__ import annotations
from copy import deepcopy
from pathlib import Path
import pytest
from cognitive_agent.application.codex_assistant import (
CodexAssistantService,
_catalog_exact_mode_match_count,
_catalog_exact_scene_template_plan,
)
from cognitive_agent.application.mode_profile_intent import (
mode_creation_source_name,
parse_mode_profile_create,
)
from cognitive_agent.application.scene_composer import (
SCENE_COMPOSER_INSTRUCTIONS,
SCHEDULE_COMPOSER_INSTRUCTIONS,
scene_context_for_mode_creation,
)
from cognitive_agent.application.scene_templates import SceneTemplateRegistry
ROOT = Path(__file__).resolve().parents[2]
@pytest.mark.parametrize(("message", "name"), [
("构建主驾阅读模式", "主驾阅读模式"),
("为主驾构建一个阅读模式", "主驾阅读模式"),
("帮我为副驾驶构建一个阅读模式", "副驾阅读模式"),
("给驾驶席创建一个阅读模式", "主驾阅读模式"),
("可以帮我为主驾构建一个阅读模式吗?", "主驾阅读模式"),
("构建一个阅读模式,针对主驾", "主驾阅读模式"),
("构建一个阅读模式,仅联动副驾座椅", "副驾阅读模式"),
("构建一个阅读模式,给主驾使用,先看看", "主驾阅读模式"),
("构建一个主驾驶座椅专属的阅读模式", "主驾阅读模式"),
("构建一个主驾适合看书的功能模式", "主驾看书模式"),
("构建一个适合主驾看书的功能模式", "主驾看书模式"),
("为主驾和副驾创建一个阅读模式", "主副驾阅读模式"),
("构建一个双座阅读模式", "主副驾阅读模式"),
("构建一个阅读模式,针对双座", "主副驾阅读模式"),
])
def test_scoped_creation_keeps_distinct_name_and_fast_template_source(message, name):
assert parse_mode_profile_create(message) == (name, "")
registry = SceneTemplateRegistry(ROOT / "config" / "scene_templates")
template = registry.resolve_exact_name(name)
assert template is not None and template.id == "reading"
@pytest.mark.parametrize(("message", "expected"), [
("为主驾构建一个阅读模式,空调调整到26度", ("主驾阅读模式", "空调调整到26度")),
("构建一个阅读模式,针对主驾,空调调整到26度", ("阅读模式", "针对主驾,空调调整到26度")),
("构建一个主驾阅读模式,靠背向后移动3s", ("主驾阅读模式", "靠背向后移动3s")),
("构建一个名为主驾驶阅读模式", ("主驾驶阅读模式", "")),
("构建一个“阅读模式”,针对主驾", ("阅读模式", "")),
])
def test_explicit_names_and_actual_device_instructions_are_preserved(message, expected):
assert parse_mode_profile_create(message) == expected
@pytest.mark.parametrize("message", [
"不要为主驾构建一个阅读模式", "如何为主驾构建一个阅读模式?",
"为主驾构建一个阅读模式,但不要执行", "引用为主驾构建一个阅读模式",
])
def test_scope_grammar_does_not_bypass_authorization(message):
assert parse_mode_profile_create(message) is None
@pytest.mark.parametrize("name", ["后排阅读模式", "左后排阅读模式", "第三排阅读模式", "未知座位阅读模式"])
def test_unsupported_seats_never_alias_to_front_seat_templates(name):
assert mode_creation_source_name(name) == name
assert SceneTemplateRegistry(ROOT / "config" / "scene_templates").resolve_exact_name(name) is None
@pytest.mark.parametrize(("name", "source", "template_id"), [
("主驾座椅舒适模式", "座椅舒适模式", "seat-comfort"),
("主驾和副驾座椅舒适模式", "座椅舒适模式", "seat-comfort"),
("主副驾座椅动态模式", "座椅动态模式", "seat-dynamic"),
("副驾座椅的动态模式", "座椅的动态模式", "seat-dynamic"),
])
def test_seat_word_is_preserved_when_it_is_the_template_subject(name, source, template_id):
assert mode_creation_source_name(name) == source
template = SceneTemplateRegistry(ROOT / "config" / "scene_templates").resolve_exact_name(name)
assert template is not None and template.id == template_id
def test_catalog_seed_fallback_is_exact_and_preserves_explicit_match_priority():
template = SceneTemplateRegistry(ROOT / "config" / "scene_templates").get_template("reading").public_dict(include_plan=True)
template["assistant_callable"] = True
catalog = {
"source_status": {"scene_templates": "ready", "modes": "ready"},
"scene_templates": [template],
"modes": [{"id": "office", "name": "办公模式"}],
}
assert _catalog_exact_scene_template_plan(catalog, "主驾阅读模式") == template["plan"]
assert _catalog_exact_scene_template_plan(catalog, "后排阅读模式") is None
assert _catalog_exact_mode_match_count(catalog, "主驾办公模式") == 1
catalog["modes"].extend([
{"id": "driver-office-a", "name": "主驾办公模式"},
{"id": "driver-office-b", "name": "主驾办公模式"},
])
assert _catalog_exact_mode_match_count(catalog, "主驾办公模式") == 2
@pytest.mark.parametrize(("message", "expected"), [
("构建一个神游模式", {"passenger"}),
("构建一个主驾神游模式", {"driver", "front_left"}),
("构建一个主副驾神游模式", {"driver", "front_left", "passenger"}),
("构建一个神游模式,主驾空调26度", {"passenger"}),
("构建一个后排神游模式", set()),
])
def test_model_context_scopes_only_seat_capabilities_and_does_not_mutate_source(message, expected):
context = {
"composition_revision": "revision", "vehicle": {"id": "denza_n7"},
"capabilities": [
{"id": "seat.driver", "domain": "seat", "target_scopes": ["driver", "front_left"]},
{"id": "seat.passenger", "domain": "seat", "target_scopes": ["passenger"]},
{"id": "climate.zone", "domain": "climate", "target_scopes": ["driver", "passenger"]},
{"id": "roof.shade", "domain": "roof", "target_scopes": ["vehicle"]},
],
}
original = deepcopy(context)
projected = scene_context_for_mode_creation(context, message)
scopes = {scope for capability in projected["capabilities"] if capability["domain"] == "seat"
for scope in capability["target_scopes"]}
assert scopes == expected
assert [capability for capability in projected["capabilities"] if capability["domain"] != "seat"] == original["capabilities"][2:]
assert context == original
assert projected["seat_scope_policy"]["default_scope"] == "passenger"
def test_static_and_dynamic_prompts_use_one_selected_seat():
assert "defaults to the passenger seat only" in SCENE_COMPOSER_INSTRUCTIONS
assert "retarget a passenger capability to the driver" in SCENE_COMPOSER_INSTRUCTIONS
assert "selected seat scope in every phase" in SCHEDULE_COMPOSER_INSTRUCTIONS
def test_identical_explicit_mode_names_do_not_share_cached_plans_across_seats():
context = {"composition_revision": "revision", "vehicle": {"id": "denza_n7"}, "capabilities": []}
# Explicitly named drafts may intentionally keep the same display name even
# when the outer request selects a different seat.
driver = scene_context_for_mode_creation(context, "为主驾构建一个名为神游模式")
passenger = scene_context_for_mode_creation(context, "为副驾构建一个名为神游模式")
driver_key = CodexAssistantService._scene_cache_key(None, mode_name="神游模式", scene_context=driver)
passenger_key = CodexAssistantService._scene_cache_key(None, mode_name="神游模式", scene_context=passenger)
assert driver_key != passenger_key
@pytest.mark.parametrize("seat", [
"两个座椅", "两张座椅", "两座椅", "两座", "两个座位", "前排两座",
"前排两座椅", "前排两个座椅", "前排两张座椅", "双座", "双座椅",
])
@pytest.mark.parametrize("pattern", ["构建一个{}阅读模式", "为{}构建一个阅读模式", "构建一个阅读模式,针对{}"])
def test_all_two_seat_wordings_have_one_canonical_name(seat, pattern):
message = pattern.format(seat)
assert parse_mode_profile_create(message) == ("主副驾阅读模式", "")
assert mode_creation_source_name(f"{seat}阅读模式") == "阅读模式"
@pytest.mark.parametrize("message", [
"构建一个主驾阅读模式,针对副驾", "构建一个副驾阅读模式,针对主驾",
"为主驾构建一个副驾阅读模式", "为副驾构建一个主驾阅读模式",
"构建一个主副驾阅读模式,只针对主驾", "构建一个主驾阅读模式,针对双座",
"为主驾构建一个名为副驾阅读模式", "构建一个“副驾阅读模式”,针对主驾",
])
def test_conflicting_seat_selection_cannot_silently_become_a_two_seat_creation(message):
assert parse_mode_profile_create(message) is None
@pytest.mark.parametrize(("name", "source"), [
("副驾驶座椅阅读模式", "阅读模式"), ("主驾座椅的阅读模式", "阅读模式"),
("主驾座椅办公模式", "办公模式"), ("副驾座位的观影模式", "观影模式"),
("副驾座椅看书模式", "看书模式"), ("主驾座椅露营模式", "露营模式"),
("主驾座椅舒适模式", "座椅舒适模式"), ("副驾座椅动态模式", "座椅动态模式"),
("乘客座椅舒适模式", "座椅舒适模式"), ("两个座椅的动态模式", "座椅动态模式"),
("两张座椅舒适模式", "座椅舒适模式"), ("前排两座动态模式", "座椅动态模式"),
])
def test_seat_location_wrappers_do_not_hide_generic_scene_sources_or_actual_seat_topics(name, source):
assert mode_creation_source_name(name) == source
@pytest.mark.parametrize("message", [
"构建一个两个座椅的动态模式", "构建一个两张座椅的动态模式", "构建一个前排两座动态模式",
])
def test_double_seat_dynamic_topic_is_not_lost_with_the_scope_qualifier(message):
assert parse_mode_profile_create(message) == ("主副驾座椅动态模式", "")
+12 -2
View File
@@ -46,8 +46,18 @@ def test_runtime_mode_catalog_exposes_safe_executable_action_details() -> None:
if mode["id"] == "office" if mode["id"] == "office"
) )
assert len(office["operations"]) == office["operation_count"] == 10 assert len(office["operations"]) == office["operation_count"] == 12
assert office["operations"][0] == { assert office["operations"][0] == {
"domain": "seat",
"capability": "seat.set_driver_ventilation_native",
"target_scope": "driver",
"operation": "set",
"parameters": {"state": "on", "level": 2},
}
assert next(
operation for operation in office["operations"]
if operation["capability"] == "seat.set_longitudinal_position"
) == {
"domain": "seat", "domain": "seat",
"capability": "seat.set_longitudinal_position", "capability": "seat.set_longitudinal_position",
"target_scope": "passenger", "target_scope": "passenger",
@@ -237,7 +247,7 @@ def test_top_mode_action_formatter_uses_device_target_action_and_parameter_value
actions = json.loads(completed.stdout) actions = json.loads(completed.stdout)
assert len(actions) == office["operation_count"] assert len(actions) == office["operation_count"]
assert actions[0] == { assert next(action for action in actions if action["action"] == "调节座椅前后位置") == {
"device": "座椅", "device": "座椅",
"target": "副驾", "target": "副驾",
"action": "调节座椅前后位置", "action": "调节座椅前后位置",
+513
View File
@@ -0,0 +1,513 @@
from __future__ import annotations
from queue import Queue
from threading import Event, Lock, current_thread
from unittest.mock import patch
import pytest
from cognitive_agent.application.mode_schedule import (
MODE_SCHEDULE_SCHEMA,
ModeSchedule,
ModeSchedulePhase,
ModeScheduleRunner,
)
def operation(capability="climate.set_temperature", target="whole_vehicle", **parameters):
return {
"domain": capability.split(".")[0], "capability": capability,
"target_scope": target, "operation": "set",
"parameters": parameters or {"temperature_c": 24}, "reason": "舒适阶段",
}
def document(*, repeat=1, duration=1000):
return {
"schema": MODE_SCHEDULE_SCHEMA, "repeat_count": repeat,
"phases": [
{"id": "warmup", "name": "预热", "duration_ms": duration,
"operations": [operation(temperature_c=28)]},
{"id": "comfort", "name": "舒适", "duration_ms": duration,
"operations": [operation(temperature_c=24)]},
],
}
def test_round_trip_has_independent_parameters_and_counts():
source = document(repeat=3, duration=1500)
schedule = ModeSchedule.from_dict(source)
assert schedule.to_dict() == source
assert schedule.total_duration_ms == 9000
assert schedule.operation_count == 2
source["phases"][0]["operations"][0]["parameters"]["temperature_c"] = 10
returned = schedule.to_dict()
returned["phases"][0]["operations"][0]["parameters"]["temperature_c"] = 11
assert schedule.phases[0].operations[0]["parameters"]["temperature_c"] == 28
@pytest.mark.parametrize("value", [None, [], "schedule", 1, True])
def test_schedule_root_must_be_object(value):
with pytest.raises(ValueError):
ModeSchedule.from_dict(value)
@pytest.mark.parametrize("field", ["schema", "repeat_count", "phases"])
def test_schedule_requires_all_contract_fields(field):
value = document()
del value[field]
with pytest.raises(ValueError, match="missing"):
ModeSchedule.from_dict(value)
@pytest.mark.parametrize("value", [0, 21, -1, 1.0, "2", True, False, None, float("nan")])
def test_repeat_count_is_a_bounded_non_boolean_integer(value):
with pytest.raises(ValueError, match="repeat_count"):
ModeSchedule.from_dict(document(repeat=value))
@pytest.mark.parametrize("value", [0, 999, 900001, 1000.0, "1000", True, None, float("inf")])
def test_phase_duration_is_a_bounded_non_boolean_integer(value):
with pytest.raises(ValueError, match="duration_ms"):
ModeSchedule.from_dict(document(duration=value))
@pytest.mark.parametrize("count", [0, 1, 9])
def test_phase_count_bounds(count):
value = document()
value["phases"] = [dict(value["phases"][0], id=f"phase-{index}") for index in range(count)]
with pytest.raises(ValueError, match="2 to 8"):
ModeSchedule.from_dict(value)
def test_total_duration_includes_repeats_and_enforces_one_hour():
assert ModeSchedule.from_dict(document(repeat=2, duration=900000)).total_duration_ms == 3600000
with pytest.raises(ValueError, match="60 minutes"):
ModeSchedule.from_dict(document(repeat=3, duration=900000))
@pytest.mark.parametrize("path", [(), ("phases", 0), ("phases", 0, "operations", 0)])
def test_unknown_fields_rejected_at_every_contract_level(path):
value = document()
target = value
for key in path:
target = target[key]
target["untrusted_instruction"] = "bypass validation"
with pytest.raises(ValueError, match="unknown"):
ModeSchedule.from_dict(value)
def test_non_string_object_keys_are_rejected():
value = document()
value[1] = "unknown"
with pytest.raises(ValueError, match="string keys"):
ModeSchedule.from_dict(value)
@pytest.mark.parametrize("schema", ["v2", "", None, 1, True])
def test_schema_is_exact(schema):
value = document()
value["schema"] = schema
with pytest.raises(ValueError, match="schema"):
ModeSchedule.from_dict(value)
@pytest.mark.parametrize("field", ["id", "name", "duration_ms", "operations"])
def test_phase_requires_all_contract_fields(field):
value = document()
del value["phases"][0][field]
with pytest.raises(ValueError, match="missing"):
ModeSchedule.from_dict(value)
@pytest.mark.parametrize("phase_id", ["", "Bad ID", "hello.world", "座椅", "x" * 65, 5])
def test_phase_ids_are_bounded_identifiers(phase_id):
value = document()
value["phases"][0]["id"] = phase_id
with pytest.raises(ValueError):
ModeSchedule.from_dict(value)
def test_duplicate_phase_ids_rejected():
value = document()
value["phases"][1]["id"] = value["phases"][0]["id"]
with pytest.raises(ValueError, match="ids must be unique"):
ModeSchedule.from_dict(value)
@pytest.mark.parametrize("value", [[], [operation()] * 13, {}, "operations"])
def test_operation_count_and_array_contract(value):
source = document()
source["phases"][0]["operations"] = value
with pytest.raises(ValueError):
ModeSchedule.from_dict(source)
@pytest.mark.parametrize("capability", [
"seat.set_driver_axis_native", "seat.set_driver_position_native", "seat.move",
"seat.apply_dynamic_mode", "door.set_vehicle_lock", "window.set_motion", "custom.anything",
])
def test_only_stable_scene_composable_capabilities_allowed(capability):
value = document()
value["phases"][0]["operations"] = [operation(capability)]
with pytest.raises(ValueError, match="stable"):
ModeSchedule.from_dict(value)
@pytest.mark.parametrize("kind", ["toggle", "increase", "decrease", "activate", "apply", None, True])
def test_relative_or_non_set_operation_types_rejected(kind):
value = document()
value["phases"][0]["operations"][0]["operation"] = kind
with pytest.raises(ValueError, match="absolute set"):
ModeSchedule.from_dict(value)
@pytest.mark.parametrize("name", ["duration_ms", "duration", "seconds", "direction", "delta", "relative", "axis", "toggle"])
def test_phase_holds_cannot_be_smuggled_as_axis_motion(name):
value = document()
value["phases"][0]["operations"][0]["parameters"][name] = 1000
with pytest.raises(ValueError, match="relative, motion or timing"):
ModeSchedule.from_dict(value)
@pytest.mark.parametrize("value", [float("nan"), float("inf"), -float("inf"), {}, [], None])
def test_non_json_or_non_scalar_parameters_rejected(value):
source = document()
source["phases"][0]["operations"][0]["parameters"]["temperature_c"] = value
with pytest.raises(ValueError):
ModeSchedule.from_dict(source)
@pytest.mark.parametrize("value", ["toggle", "relative", "increase", "decrease"])
def test_relative_parameter_values_rejected(value):
source = document()
source["phases"][0]["operations"][0]["parameters"]["state"] = value
with pytest.raises(ValueError, match="relative state"):
ModeSchedule.from_dict(source)
def test_domain_must_match_capability():
value = document()
value["phases"][0]["operations"][0]["domain"] = "seat"
with pytest.raises(ValueError, match="stable"):
ModeSchedule.from_dict(value)
@pytest.mark.parametrize("target", ["passenger", "front_right"])
def test_duplicate_physical_seat_target_aliases_rejected(target):
value = document()
value["phases"][0]["operations"] = [
operation("seat.set_ventilation", "passenger", state="on", level=1),
operation("seat.set_ventilation", target, state="off"),
]
with pytest.raises(ValueError, match="duplicate"):
ModeSchedule.from_dict(value)
def test_driver_and_passenger_comfort_are_valid_independent_operations():
value = document()
value["phases"][0]["operations"] = [
operation("seat.set_driver_ventilation_native", "driver", state="on", level=2),
operation("seat.set_driver_massage_native", "driver", action="mode", mode="relax"),
operation("seat.set_ventilation", "passenger", state="on", level=1),
operation("seat.set_backrest_recline", "passenger", position_pct=40),
]
assert ModeSchedule.from_dict(value).operation_count == 5
def test_direct_dataclass_construction_cannot_bypass_duration_or_repeat_limits():
with pytest.raises(ValueError, match="duration_ms"):
ModeSchedulePhase("a", "a", True, (operation(),))
phase = ModeSchedulePhase.from_dict(document()["phases"][0])
second = ModeSchedulePhase.from_dict(document()["phases"][1])
with pytest.raises(ValueError, match="repeat_count"):
ModeSchedule((phase, second), True)
def test_simulation_has_no_thread_or_real_wait_and_preserves_repetition_order():
schedule = ModeSchedule.from_dict(document(repeat=3, duration=2000))
calls = []
caller = current_thread()
def execute(phase):
assert current_thread() is caller
calls.append(phase.id)
return {"success": True}
with (
patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("thread")),
patch("cognitive_agent.application.mode_schedule.Event.wait", side_effect=AssertionError("wait")),
):
result = ModeScheduleRunner.simulate(schedule, execute)
assert result["status"] == "completed"
assert result["completed"] is True
assert result["execution_mode"] == "dry_run"
assert result["virtual_elapsed_ms"] == 12000
assert result["executed_phases"] == 6
assert result["phase_index"] == 1
assert result["repeat_index"] == 2
assert calls == ["warmup", "comfort"] * 3
assert [item["virtual_start_ms"] for item in result["phase_results"]] == list(range(0, 12000, 2000))
@pytest.mark.parametrize("failed_result", [
None, [], {}, {"success": False}, {"status": "failed"}, {"status": "submitted_unverified"},
{"status": "error", "success": True}, {"status": "succeeded", "success": False},
{"success": True, "error": "rejected by safety"},
{"status": "pending"}, {"success": 1},
])
def test_simulation_fails_closed_on_unconfirmed_or_failed_execution(failed_result):
calls = []
def execute(phase):
calls.append(phase.id)
return failed_result
result = ModeScheduleRunner.simulate(ModeSchedule.from_dict(document(repeat=3)), execute)
assert result["status"] == "failed"
assert result["completed"] is False
assert result["error"]
assert result["virtual_elapsed_ms"] == 0
assert calls == ["warmup"]
def test_simulation_stops_on_second_phase_exception_and_reports_prior_hold():
def execute(phase):
if phase.id == "comfort":
raise RuntimeError("runtime safety rejection")
return {"status": "succeeded"}
result = ModeScheduleRunner.simulate(ModeSchedule.from_dict(document(repeat=3)), execute)
assert result["completed"] is False
assert result["error"] == "RuntimeError: runtime safety rejection"
assert result["virtual_elapsed_ms"] == 1000
assert result["executed_phases"] == 1
def test_executor_mutations_do_not_change_next_repetition_or_source():
values = []
schedule = ModeSchedule.from_dict(document(repeat=2))
def execute(phase):
values.append(phase.operations[0]["parameters"]["temperature_c"])
phase.operations[0]["parameters"]["temperature_c"] = 999
return {"status": "ok"}
assert ModeScheduleRunner.simulate(schedule, execute)["completed"]
assert values == [28, 24, 28, 24]
assert schedule.phases[0].operations[0]["parameters"]["temperature_c"] == 28
class ManualClock:
def __init__(self):
self.value = 0.0
self.holds = Queue()
def __call__(self):
return self.value
def wait(self, cancelled, seconds):
release = Event()
self.holds.put((seconds, release))
while not cancelled.is_set():
if release.wait(0.01):
self.value += seconds
return False
return True
def release(self, seconds=1.0):
expected, release = self.holds.get(timeout=2)
assert expected == seconds
release.set()
def test_runner_holds_without_repeated_dispatch_and_finishes_finite_repeats():
clock = ManualClock()
calls = Queue()
runner = ModeScheduleRunner(clock=clock, wait=clock.wait)
schedule = ModeSchedule.from_dict(document(repeat=2))
try:
runner.start("car", schedule, lambda phase: calls.put(phase.id) or {"success": True}, session_id="a")
for expected in ["warmup", "comfort", "warmup", "comfort"]:
assert calls.get(timeout=2) == expected
assert calls.empty()
state = runner.status("car")
assert state["status"] == "running"
assert state["completed"] is False
assert state["session_id"] == "a"
clock.release()
# Joining a bounded, virtual-time worker is deterministic and does not
# cancel it as close() would do while it leaves its final hold.
runner._runs["car"].thread.join(2)
assert runner.status("car")["status"] == "completed"
assert runner.status("car")["elapsed_ms"] == 4000
assert runner.status("car")["executed_phases"] == 4
finally:
assert runner.close()
def test_cancel_interrupts_real_hold_before_next_phase():
started = Event()
calls = []
runner = ModeScheduleRunner()
def execute(phase):
calls.append(phase.id)
started.set()
return {"status": "success"}
runner.start("car", ModeSchedule.from_dict(document(duration=900000)), execute)
assert started.wait(2)
assert runner.cancel("car")["status"] == "cancelled"
assert runner.close(timeout=1)
assert calls == ["warmup"]
assert runner.status("car")["completed"] is False
def test_wrong_session_cannot_cancel_active_timeline():
started = Event()
runner = ModeScheduleRunner()
runner.start("car", ModeSchedule.from_dict(document()), lambda phase: started.set() or {"success": True}, session_id="a")
assert started.wait(2)
assert runner.cancel("car", session_id="b")["status"] == "running"
assert runner.cancel("car", session_id="a")["status"] == "cancelled"
assert runner.close()
def test_replace_waits_for_inflight_callback_and_never_overlaps_same_car():
entered = Event()
release = Event()
second_entered = Event()
calls = []
inside = 0
guard = Lock()
runner = ModeScheduleRunner()
def first(phase):
nonlocal inside
with guard:
inside += 1
entered.set()
assert release.wait(2)
calls.append("old")
with guard:
inside -= 1
return {"success": True}
def second(phase):
with guard:
assert inside == 0
calls.append("new")
second_entered.set()
return {"success": True}
try:
old = runner.start("car", ModeSchedule.from_dict(document()), first, session_id="old")
old_run = runner._runs["car"]
assert entered.wait(2)
new = runner.start("car", ModeSchedule.from_dict(document()), second, session_id="new")
assert old["run_id"] != new["run_id"]
assert old_run.status == "cancelled"
assert old_run.cancel_reason == "replaced"
assert not second_entered.is_set()
release.set()
assert second_entered.wait(2)
assert calls == ["old", "new"]
finally:
release.set()
assert runner.close()
def test_different_vehicles_do_not_share_a_global_execution_lock():
first_entered, second_entered, release = Event(), Event(), Event()
runner = ModeScheduleRunner()
def first(phase):
first_entered.set()
assert release.wait(2)
return {"success": True}
try:
runner.start("car-a", ModeSchedule.from_dict(document()), first)
assert first_entered.wait(2)
runner.start("car-b", ModeSchedule.from_dict(document()), lambda phase: second_entered.set() or {"success": True})
assert second_entered.wait(2)
finally:
release.set()
assert runner.close()
@pytest.mark.parametrize("failure", ["return", "exception"])
def test_runner_stops_later_phases_and_repeats_after_failure(failure):
calls = []
runner = ModeScheduleRunner(wait=lambda event, duration: False)
def execute(phase):
calls.append(phase.id)
if phase.id == "comfort":
if failure == "exception":
raise ValueError("bad runtime capability")
return {"status": "rejected", "error": "bad runtime capability"}
return {"status": "ok"}
runner.start("car", ModeSchedule.from_dict(document(repeat=3)), execute)
runner._runs["car"].thread.join(2)
assert runner.status("car")["status"] == "failed"
assert "bad runtime capability" in runner.status("car")["error"]
assert runner.status("car")["executed_phases"] == 1
assert calls == ["warmup", "comfort"]
assert runner.close()
def test_close_prevents_start_and_reports_uninterruptible_callback_honestly():
entered, release = Event(), Event()
runner = ModeScheduleRunner()
runner.start("car", ModeSchedule.from_dict(document()), lambda phase: entered.set() or release.wait(2) or {"success": True})
assert entered.wait(2)
assert runner.close(timeout=0) is False
assert runner.status("car")["status"] == "cancelled"
assert runner.status("car")["cancel_reason"] == "closed"
with pytest.raises(RuntimeError, match="closed"):
runner.start("car", ModeSchedule.from_dict(document()), lambda phase: {"success": True})
release.set()
assert runner.close()
def test_idle_and_terminal_cancellation_are_idempotent():
runner = ModeScheduleRunner(wait=lambda event, duration: False)
assert runner.status("unknown")["status"] == "idle"
assert runner.cancel("unknown")["status"] == "idle"
runner.start("car", ModeSchedule.from_dict(document()), lambda phase: {"success": True})
runner._runs["car"].thread.join(2)
assert runner.cancel("car")["status"] == "completed"
assert runner.close()
assert runner.close()
def test_start_revalidates_caller_mutation_before_accepting_schedule():
runner = ModeScheduleRunner()
schedule = ModeSchedule.from_dict(document())
schedule.phases[0].operations[0]["operation"] = "toggle"
with pytest.raises(ValueError, match="absolute set"):
runner.start("car", schedule, lambda phase: {"success": True})
assert runner.status("car")["status"] == "idle"
assert runner.close()
def test_failed_thread_start_is_not_left_running_and_close_remains_safe():
runner = ModeScheduleRunner()
with (
patch("cognitive_agent.application.mode_schedule.Thread.start", side_effect=RuntimeError("thread unavailable")),
pytest.raises(RuntimeError, match="thread unavailable"),
):
runner.start("car", ModeSchedule.from_dict(document()), lambda phase: {"success": True})
assert runner.status("car")["status"] == "failed"
assert runner.close()
def test_injected_cancelled_wait_cannot_leave_stale_running_status():
runner = ModeScheduleRunner(wait=lambda event, duration: True)
runner.start("car", ModeSchedule.from_dict(document()), lambda phase: {"success": True})
runner._runs["car"].thread.join(2)
assert runner.status("car")["status"] == "cancelled"
assert runner.close()
@@ -46,6 +46,26 @@ def test_saved_mode_update_parser_accepts_natural_variants(
assert parse_mode_profile_update(message) == expected assert parse_mode_profile_update(message) == expected
@pytest.mark.parametrize("change", (
"第二阶段保持30秒", "第二阶段持续16分钟", "循环21次", "重复两次",
"重复1.5次", "持续30秒", "全部阶段持续2分钟",
))
def test_schedule_update_predicates_stay_on_control_plane_even_when_bounds_are_invalid(change):
assert parse_mode_profile_update(f"把上车预热模式的{change}") == ("上车预热模式", change)
@pytest.mark.parametrize("message", (
"把上车预热模式的第二阶段保持30秒合适吗?",
"不要把上车预热模式的第二阶段保持30秒",
"上车预热模式的第二阶段不要保持30秒",
"把上车预热模式的重复两次,但是不要保存",
"解释把上车预热模式的重复两次",
"创建一个上车预热模式,第二阶段保持30秒",
))
def test_schedule_update_predicates_do_not_authorize_queries_negation_or_creation(message):
assert parse_mode_profile_update(message) is None
@pytest.mark.parametrize( @pytest.mark.parametrize(
"message", "message",
( (
+7 -1
View File
@@ -792,7 +792,13 @@ def test_unknown_mode_uses_one_structured_turn_then_local_dry_run() -> None:
"roof.set_shade_position", "roof.set_shade_position",
] ]
call = client.structured_calls[0] call = client.structured_calls[0]
assert call["application_context"] == _scene_context() expected_context = _scene_context()
expected_context["seat_scope_policy"] = {
"selected_scopes": ["passenger"],
"default_scope": "passenger",
"applies_to": "seat capabilities in every phase",
}
assert call["application_context"] == expected_context
assert call["output_schema"]["type"] == "object" assert call["output_schema"]["type"] == "object"
assert call["output_schema"]["additionalProperties"] is False assert call["output_schema"]["additionalProperties"] is False
assert "requests" in call["output_schema"]["required"] assert "requests" in call["output_schema"]["required"]