feat: add seat-scoped static and dynamic assistant modes
This commit is contained in:
+217
-13
@@ -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 () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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档。}
|
||||||
@@ -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: 使用安静低风量。}
|
||||||
|
|||||||
@@ -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,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: 关闭室内主照明。}
|
||||||
|
|||||||
@@ -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: 使用安静低风量。}
|
||||||
|
|||||||
@@ -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: 开启室内主照明。}
|
||||||
|
|||||||
@@ -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: 关闭室内主照明。}
|
||||||
|
|||||||
@@ -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: 腿托小幅回落,不使用无界轴运动。}
|
||||||
@@ -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: 使用适中风量快速降温。}
|
||||||
|
|||||||
@@ -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: 使用中低风量。}
|
||||||
|
|||||||
@@ -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()))
|
||||||
@@ -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:
|
||||||
|
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:
|
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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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; });
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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) == ("主副驾座椅动态模式", "")
|
||||||
@@ -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": "调节座椅前后位置",
|
||||||
|
|||||||
@@ -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",
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user