diff --git a/README.md b/README.md
index abc440d..2cdc0bb 100755
--- a/README.md
+++ b/README.md
@@ -2,6 +2,8 @@
面向车载执行器的认知编排 Agent 单仓工程。系统把自然语言请求转换为可校验的语义配置,再由确定性的领域插件编译为 CAN 命令计划,最后经安全策略和执行器下发。
+启动前端:PYTHONPATH=src:domains/seat/src .omx/python311-venv/bin/python -m cognitive_agent.server
+
## 建设阶段
- 阶段一:每个领域独立完成能力定义、功能配置生成、配置校验、CAN 编译和单执行器测试。
diff --git a/apps/web-console/app.js b/apps/web-console/app.js
index 0a77b38..5853616 100755
--- a/apps/web-console/app.js
+++ b/apps/web-console/app.js
@@ -14,6 +14,7 @@ const state = {
provider: "openai",
executionMode: "dry_run",
conversationId: loadConversationId(),
+ vehicleId: null,
runtime: null,
result: null,
progressTimer: null,
@@ -37,8 +38,8 @@ const TWIN_DEFAULT_POSITIONS = {
legrest_extension: 0,
};
const TWIN_ANGLE_RANGES = {
- backrest_recline: [95, 140],
- legrest_height: [95, 160],
+ backrest_recline: [95, 150],
+ legrest_height: [95, 163],
};
function initJointTwinLayout() {
@@ -2436,11 +2437,132 @@ function selectExecutionMode(mode) {
refreshIcons();
}
+function vehicleProfiles() {
+ return Array.isArray(state.runtime?.vehicles) ? state.runtime.vehicles : [];
+}
+
+function findVehicleProfile(vehicleId) {
+ return vehicleProfiles().find((vehicle) => vehicle.id === vehicleId)
+ || state.runtime?.vehicle
+ || null;
+}
+
+function applyTwinAngleRanges(vehicle) {
+ Object.keys(TWIN_ANGLE_RANGES).forEach((axis) => delete TWIN_ANGLE_RANGES[axis]);
+ const motors = vehicle?.seat?.motors || {};
+ Object.entries(motors).forEach(([axis, motor]) => {
+ const display = motor?.display_angle;
+ if (!display) return;
+ const zero = Number(display.zero_deg);
+ const maximum = Number(display.max_deg);
+ if (Number.isFinite(zero) && Number.isFinite(maximum) && maximum > zero) {
+ TWIN_ANGLE_RANGES[axis] = [zero, maximum];
+ }
+ });
+}
+
+function selectVehicle(vehicleId, { silent = false } = {}) {
+ const vehicle = findVehicleProfile(vehicleId);
+ if (!vehicle) return;
+ state.vehicleId = vehicle.id;
+ const select = $("#vehicleSelect");
+ if (select) select.value = vehicle.id;
+ applyTwinAngleRanges(vehicle);
+ const longitudinal = vehicle.seat?.motors?.longitudinal;
+ const backrest = vehicle.seat?.motors?.backrest_recline;
+ $("#vehicleNote").textContent = longitudinal && backrest
+ ? `${vehicle.name} · 水平 ${longitudinal.range}${longitudinal.unit} · 靠背 ${backrest.range}${backrest.unit}`
+ : `${vehicle.name} · 已加载车型配置`;
+ if (state.result?.actuator_state) {
+ renderSeatState(state.result.actuator_state);
+ } else if (state.seatTwin?.actuatorState) {
+ updateSeatTwin(state.seatTwin.actuatorState);
+ }
+ if (!silent) showToast(`已切换车型:${vehicle.name}`);
+}
+
+function populateVehicleSelector() {
+ const select = $("#vehicleSelect");
+ if (!select) return;
+ const vehicles = vehicleProfiles();
+ select.innerHTML = vehicles.length
+ ? vehicles.map((vehicle) => ``).join("")
+ : '';
+ const defaultVehicleId = state.runtime?.default_vehicle_id
+ || state.runtime?.vehicle?.id
+ || vehicles[0]?.id
+ || "";
+ select.disabled = vehicles.length === 0;
+ if (defaultVehicleId) selectVehicle(defaultVehicleId, { silent: true });
+}
+
+function modeProfiles() {
+ return Array.isArray(state.runtime?.modes) ? state.runtime.modes : [];
+}
+
+function modeChipLabel(mode) {
+ const name = String(mode?.name || mode?.id || "模式");
+ return name.endsWith("模式") && name.length > 2
+ ? name.slice(0, -2)
+ : name;
+}
+
+function populateModeChips() {
+ const group = $(".mode-chips");
+ if (!group) return;
+ const buttons = () => $$("button", group);
+ modeProfiles().forEach((mode) => {
+ const label = modeChipLabel(mode);
+ let button = buttons().find((item) => item.dataset.modeProfileId === mode.id)
+ || buttons().find((item) => item.textContent.trim() === label);
+ if (!button) {
+ button = document.createElement("button");
+ button.type = "button";
+ const icon = document.createElement("i");
+ icon.dataset.lucide = mode.icon || "bookmark";
+ button.append(icon, document.createTextNode(label));
+ group.append(button);
+ }
+ button.dataset.modeProfileId = mode.id;
+ button.dataset.modeCommand = `进入${mode.name}`;
+ button.title = mode.description || mode.name;
+ });
+ refreshIcons();
+}
+
+function applyModeCommand(command) {
+ if (!command) return;
+ $("#utterance").value = command;
+ $("#requestForm").requestSubmit();
+}
+
+function toggleSaveModeComposer() {
+ const composer = $("#saveModeComposer");
+ composer.hidden = !composer.hidden;
+ if (!composer.hidden) $("#saveModeName").focus();
+}
+
+function saveCurrentModeProfile() {
+ const input = $("#saveModeName");
+ const rawName = input.value.trim();
+ if (!rawName) {
+ showToast("请输入模式名称");
+ return;
+ }
+ const modeName = rawName.endsWith("模式") ? rawName : `${rawName}模式`;
+ $("#utterance").value = `这个模式不错,固化为${modeName}`;
+ $("#saveModeComposer").hidden = true;
+ input.value = "";
+ $("#requestForm").requestSubmit();
+}
+
async function loadRuntime() {
try {
const response = await fetch("/api/runtime", { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
state.runtime = await response.json();
+ populateVehicleSelector();
+ populateModeChips();
const hardware = state.runtime.hardware || {};
const badge = $("#modelBadge");
const gptButton = $('[data-provider="openai"]');
@@ -3099,13 +3221,16 @@ function renderExecution(result) {
const reportSucceeded = report.spec.status === "succeeded";
summary.className = `execution-summary ${reportSucceeded ? "succeeded" : "failed"}`;
const presetSave = Boolean(result.preset_config);
+ const modeSave = Boolean(result.mode_profile);
const physical = report.spec.mode === "hil";
const executedDomains = [...new Set(report.spec.results.map((item) => item.domain))];
const motionCount = report.spec.results.filter((item) => item.telemetry?.motion_axis).length;
const firmwareResult = report.spec.results.find((item) => item.telemetry?.firmware_verified);
const operationResults = report.spec.results.filter((item) => item.ref !== "seat-controller-firmware");
- let executionDetail = presetSave
- ? "当前五轴姿态已持久化到会话预设仓库,未生成 CAN 命令"
+ let executionDetail = modeSave
+ ? `长期模式 ${result.mode_profile.name} 已写入配置库,未生成 CAN 命令`
+ : presetSave
+ ? "当前五轴姿态已持久化到会话预设仓库,未生成 CAN 命令"
: physical
? `${executedDomains.join(" / ")} 已下发真实 CAN,并以 IAR 固件实际反馈确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤`
: `${executedDomains.join(" / ")} 共 ${report.spec.results.length} 个操作已完成 CAN 编码与虚拟执行`;
@@ -3132,7 +3257,7 @@ function renderExecution(result) {
? "真实台架未返回满足目标的反馈,移动链路已尝试发送 STOP_ALL。"
: "真实台架未返回满足目标的物理反馈。";
}
- summary.innerHTML = `
${reportSucceeded ? (presetSave ? "姿态预设已保存" : (physical ? "真实台架执行成功" : "虚拟执行成功")) : "真实台架执行失败"}${executionDetail}
`;
+ summary.innerHTML = `${reportSucceeded ? (modeSave ? "长期模式已保存" : presetSave ? "姿态预设已保存" : (physical ? "真实台架执行成功" : "虚拟执行成功")) : "真实台架执行失败"}${executionDetail}
`;
$("#resultNavMeta").textContent = reportSucceeded
? `${report.spec.results.length} 项完成 · ${physical ? "HIL" : "DRY-RUN"}`
: "执行失败 · 查看回读";
@@ -3171,7 +3296,9 @@ function renderResult(result) {
ambient_light: "氛围灯",
};
$("#resultTitle").textContent = executionSucceeded
- ? result.preset_config
+ ? result.mode_profile
+ ? "长期模式配置已保存"
+ : result.preset_config
? "座椅姿态预设已保存"
: result.scene_config
? `${domains.length} 域场景闭环完成`
@@ -3192,6 +3319,7 @@ function renderResult(result) {
renderDomainState(result.domain_state);
updateTwinExplicitAngles(result);
renderSavedPresets(result);
+ if (result.mode_profile) loadRuntime();
renderCode(result);
renderExecution(result);
window.dispatchEvent(new CustomEvent("cognitive-agent:result", { detail: result }));
@@ -3226,6 +3354,7 @@ async function submitRequest(event) {
utterance,
provider: state.provider,
conversation_id: state.conversationId,
+ vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
execution_mode: state.executionMode,
...(state.executionMode === "hardware"
? { approval_token: approvalToken }
@@ -3352,6 +3481,22 @@ document.addEventListener("DOMContentLoaded", async () => {
});
$("#newConversation").addEventListener("click", startNewConversation);
$("#logoutButton").addEventListener("click", logout);
+ $("#vehicleSelect").addEventListener("change", (event) => {
+ selectVehicle(event.target.value);
+ });
+ $(".mode-chips").addEventListener("click", (event) => {
+ const target = event.target instanceof Element ? event.target : null;
+ const button = target?.closest("button[data-mode-command]");
+ if (button) applyModeCommand(button.dataset.modeCommand);
+ });
+ $("#saveModeToggle").addEventListener("click", toggleSaveModeComposer);
+ $("#saveModeConfirm").addEventListener("click", saveCurrentModeProfile);
+ $("#saveModeName").addEventListener("keydown", (event) => {
+ if (event.key === "Enter" && !event.isComposing) {
+ event.preventDefault();
+ saveCurrentModeProfile();
+ }
+ });
$$(".segment[data-provider]").forEach((button) => button.addEventListener("click", () => selectProvider(button.dataset.provider)));
$$('[data-execution-mode]').forEach((button) => button.addEventListener("click", () => selectExecutionMode(button.dataset.executionMode)));
$$(".example-command").forEach((button) => button.addEventListener("click", () => {
diff --git a/apps/web-console/index.html b/apps/web-console/index.html
index ba1887e..f1bca97 100755
--- a/apps/web-console/index.html
+++ b/apps/web-console/index.html
@@ -10,7 +10,7 @@
-
+
@@ -51,7 +51,7 @@
-
+
@@ -95,6 +95,17 @@
主驾和副驾暂时共享同一组实体电机、加热与通风执行器
+
+
+
+
+
+
+
正在读取座椅量程与角度标定
+
+
diff --git a/apps/web-console/styles.css b/apps/web-console/styles.css
index 5de8b11..444f1f2 100755
--- a/apps/web-console/styles.css
+++ b/apps/web-console/styles.css
@@ -1086,6 +1086,37 @@ textarea:focus {
margin-top: 18px;
}
+.vehicle-select-control {
+ min-height: 40px;
+ display: grid;
+ grid-template-columns: 18px minmax(0, 1fr);
+ align-items: center;
+ gap: 9px;
+ padding: 0 11px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: var(--surface-muted);
+}
+
+.vehicle-select-control svg {
+ width: 17px;
+ height: 17px;
+ color: var(--teal);
+}
+
+.vehicle-select-control select {
+ min-width: 0;
+ width: 100%;
+ height: 38px;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: var(--ink);
+ font: inherit;
+ font-size: 13px;
+ font-weight: 750;
+}
+
.segmented {
display: grid;
grid-template-columns: 1fr 1fr;
diff --git a/config/modes/vehicles/denza_n7/baby_nap.yaml b/config/modes/vehicles/denza_n7/baby_nap.yaml
new file mode 100644
index 0000000..bc7922f
--- /dev/null
+++ b/config/modes/vehicles/denza_n7/baby_nap.yaml
@@ -0,0 +1,76 @@
+id: baby_nap
+name: 宝宝午睡模式
+aliases:
+ - 宝宝午睡
+description: 腾势 N7 宝宝午睡姿态,并联动当前已接入的恒温、低风、内循环、遮阳帘和极暗暖光能力。
+icon: baby
+scope:
+ vehicle_ids:
+ - denza_n7
+operations:
+ - domain: seat
+ capability: seat.set_longitudinal_position
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 19
+ reason: 宝宝午睡模式水平电机目标 40mm,按腾势 N7 215mm 行程换算。
+ - domain: seat
+ capability: seat.set_cushion_tilt
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 30
+ reason: 宝宝午睡模式坐盆电机目标。
+ - domain: seat
+ capability: seat.set_backrest_recline
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 41
+ angle_deg: 123
+ reason: 宝宝午睡模式靠背电机目标 28 度,按腾势 N7 显示夹角换算。
+ - domain: seat
+ capability: seat.set_legrest_height
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 0
+ angle_deg: 95
+ reason: 宝宝午睡模式腿托电机目标 0 度。
+ - domain: climate
+ capability: climate.set_temperature
+ target_scope: vehicle
+ operation: set
+ parameters:
+ temperature_c: 24
+ reason: 全车恒温锁定 24 度。
+ - domain: climate
+ capability: climate.set_fan_level
+ target_scope: vehicle
+ operation: set
+ parameters:
+ level: 1
+ reason: 风量强制最低静音 1 档。
+ - domain: climate
+ capability: climate.set_cycle_mode
+ target_scope: vehicle
+ operation: set
+ parameters:
+ mode: internal
+ reason: 自动切换内循环。
+ - domain: roof
+ capability: roof.set_shade_position
+ target_scope: vehicle
+ operation: set
+ parameters:
+ position_pct: 10
+ reason: 全景天幕遮阳帘 90% 关闭,换算为 10% 打开。
+ - domain: ambient_light
+ capability: ambient_light.set_illumination
+ target_scope: vehicle
+ operation: set
+ parameters:
+ color_hex: "#FFF4E0"
+ brightness_pct: 5
+ reason: 极暗暖白光氛围。
diff --git a/config/modes/vehicles/denza_n7/calf_decompression_float.yaml b/config/modes/vehicles/denza_n7/calf_decompression_float.yaml
new file mode 100644
index 0000000..3bc7c2a
--- /dev/null
+++ b/config/modes/vehicles/denza_n7/calf_decompression_float.yaml
@@ -0,0 +1,41 @@
+id: calf_decompression_float
+name: 小腿解压悬浮模式
+aliases:
+ - 小腿解压
+ - 小腿悬浮
+description: 腾势 N7 副驾小腿解压悬浮姿态。
+icon: activity
+scope:
+ vehicle_ids:
+ - denza_n7
+operations:
+ - domain: seat
+ capability: seat.set_longitudinal_position
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 49
+ reason: 小腿解压悬浮模式水平电机目标 105mm,按腾势 N7 215mm 行程换算。
+ - domain: seat
+ capability: seat.set_cushion_tilt
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 75
+ reason: 小腿解压悬浮模式坐盆电机目标。
+ - domain: seat
+ capability: seat.set_backrest_recline
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 0
+ angle_deg: 95
+ reason: 小腿解压悬浮模式靠背电机目标 0 度。
+ - domain: seat
+ capability: seat.set_legrest_height
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 92
+ angle_deg: 167
+ reason: 小腿解压悬浮模式腿托电机目标 72 度,按腾势 N7 显示夹角换算。
diff --git a/config/modes/vehicles/denza_n7/cruise_sofa.yaml b/config/modes/vehicles/denza_n7/cruise_sofa.yaml
new file mode 100644
index 0000000..f203278
--- /dev/null
+++ b/config/modes/vehicles/denza_n7/cruise_sofa.yaml
@@ -0,0 +1,40 @@
+id: cruise_sofa
+name: 巡航沙发模式
+aliases:
+ - 巡航沙发
+description: 腾势 N7 副驾巡航沙发姿态。
+icon: armchair
+scope:
+ vehicle_ids:
+ - denza_n7
+operations:
+ - domain: seat
+ capability: seat.set_longitudinal_position
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 49
+ reason: 巡航沙发模式水平电机目标 105mm,按腾势 N7 215mm 行程换算。
+ - domain: seat
+ capability: seat.set_cushion_tilt
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 30
+ reason: 巡航沙发模式坐盆电机目标。
+ - domain: seat
+ capability: seat.set_backrest_recline
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 44
+ angle_deg: 125
+ reason: 巡航沙发模式靠背电机目标 30 度,按腾势 N7 显示夹角换算。
+ - domain: seat
+ capability: seat.set_legrest_height
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 49
+ angle_deg: 133
+ reason: 巡航沙发模式腿托电机目标 38 度,按腾势 N7 显示夹角换算。
diff --git a/config/modes/vehicles/denza_n7/easy_entry_exit.yaml b/config/modes/vehicles/denza_n7/easy_entry_exit.yaml
new file mode 100644
index 0000000..f72e80e
--- /dev/null
+++ b/config/modes/vehicles/denza_n7/easy_entry_exit.yaml
@@ -0,0 +1,40 @@
+id: easy_entry_exit
+name: 轻松进出模式
+aliases:
+ - 轻松进出
+description: 腾势 N7 副驾上下车进出姿态;关门后自动恢复坐好姿态需要门状态触发,当前暂不落库。
+icon: log-in
+scope:
+ vehicle_ids:
+ - denza_n7
+operations:
+ - domain: seat
+ capability: seat.set_longitudinal_position
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 0
+ reason: 轻松进出模式上下车水平电机目标 0mm。
+ - domain: seat
+ capability: seat.set_cushion_tilt
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 0
+ reason: 轻松进出模式上下车坐盆电机目标。
+ - domain: seat
+ capability: seat.set_backrest_recline
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 0
+ angle_deg: 95
+ reason: 轻松进出模式上下车靠背电机目标 0 度。
+ - domain: seat
+ capability: seat.set_legrest_height
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 0
+ angle_deg: 95
+ reason: 轻松进出模式上下车腿托电机目标 0 度。
diff --git a/config/modes/vehicles/denza_n7/electric_beach_chair.yaml b/config/modes/vehicles/denza_n7/electric_beach_chair.yaml
new file mode 100644
index 0000000..24d9c2b
--- /dev/null
+++ b/config/modes/vehicles/denza_n7/electric_beach_chair.yaml
@@ -0,0 +1,62 @@
+id: electric_beach_chair
+name: 电感沙滩椅
+aliases:
+ - 电感沙滩椅模式
+ - 沙滩椅模式
+description: 腾势 N7 电感沙滩椅姿态,并联动当前已接入的恒温、天幕遮阳和出风能力。
+icon: waves
+scope:
+ vehicle_ids:
+ - denza_n7
+operations:
+ - domain: seat
+ capability: seat.set_longitudinal_position
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 0
+ reason: 电感沙滩椅水平电机目标 0mm。
+ - domain: seat
+ capability: seat.set_cushion_tilt
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 0
+ reason: 电感沙滩椅坐盆电机目标。
+ - domain: seat
+ capability: seat.set_backrest_recline
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 100
+ angle_deg: 163
+ reason: 电感沙滩椅靠背电机目标 68 度,按腾势 N7 显示夹角换算。
+ - domain: seat
+ capability: seat.set_legrest_height
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 55
+ angle_deg: 138
+ reason: 电感沙滩椅腿托电机目标 43 度,按腾势 N7 显示夹角换算。
+ - domain: climate
+ capability: climate.set_temperature
+ target_scope: vehicle
+ operation: set
+ parameters:
+ temperature_c: 23
+ reason: 座舱锁定 23 度恒温。
+ - domain: climate
+ capability: climate.set_outlet_mode
+ target_scope: passenger
+ operation: set
+ parameters:
+ mode: face
+ reason: 水平上吹近似映射为当前能力支持的面部出风。
+ - domain: roof
+ capability: roof.set_shade_position
+ target_scope: vehicle
+ operation: set
+ parameters:
+ position_pct: 0
+ reason: 全景天幕遮阳帘 100% 完全关闭。
diff --git a/config/modes/vehicles/denza_n7/leisure_lounger.yaml b/config/modes/vehicles/denza_n7/leisure_lounger.yaml
new file mode 100644
index 0000000..163e364
--- /dev/null
+++ b/config/modes/vehicles/denza_n7/leisure_lounger.yaml
@@ -0,0 +1,83 @@
+id: leisure_lounger
+name: 休闲躺椅模式
+aliases:
+ - 休闲躺椅
+description: 腾势 N7 副驾休闲躺椅姿态,并联动当前已接入的空调、天幕和氛围灯能力。
+icon: sofa
+scope:
+ vehicle_ids:
+ - denza_n7
+operations:
+ - domain: seat
+ capability: seat.set_longitudinal_position
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 49
+ reason: 休闲躺椅模式水平电机目标 105mm,按腾势 N7 215mm 行程换算。
+ - domain: seat
+ capability: seat.set_cushion_tilt
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 30
+ reason: 休闲躺椅模式坐盆电机目标。
+ - domain: seat
+ capability: seat.set_backrest_recline
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 66
+ angle_deg: 140
+ reason: 休闲躺椅模式靠背电机目标 45 度,按腾势 N7 显示夹角换算。
+ - domain: seat
+ capability: seat.set_legrest_height
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 71
+ angle_deg: 150
+ reason: 休闲躺椅模式腿托电机目标 55 度,按腾势 N7 显示夹角换算。
+ - domain: climate
+ capability: climate.set_temperature
+ target_scope: rear_right
+ operation: set
+ parameters:
+ temperature_c: 25
+ reason: 二排右侧温区调至 24-26 度舒适区间中值。
+ - domain: climate
+ capability: climate.set_fan_level
+ target_scope: vehicle
+ operation: set
+ parameters:
+ level: 4
+ reason: 空调风量中档。
+ - domain: climate
+ capability: climate.set_cycle_mode
+ target_scope: vehicle
+ operation: set
+ parameters:
+ mode: external
+ reason: 自动切换外循环。
+ - domain: climate
+ capability: climate.set_outlet_mode
+ target_scope: rear_right
+ operation: set
+ parameters:
+ mode: feet
+ reason: 当前能力支持吹脚,腰部出风暂未接入。
+ - domain: roof
+ capability: roof.set_shade_position
+ target_scope: vehicle
+ operation: set
+ parameters:
+ position_pct: 0
+ reason: 全景天幕遮阳帘完全关闭。
+ - domain: ambient_light
+ capability: ambient_light.set_illumination
+ target_scope: vehicle
+ operation: set
+ parameters:
+ color_hex: "#FFB347"
+ brightness_pct: 20
+ reason: 低亮度暖光氛围。
diff --git a/config/modes/vehicles/denza_n7/meditation_space.yaml b/config/modes/vehicles/denza_n7/meditation_space.yaml
new file mode 100644
index 0000000..ad688bb
--- /dev/null
+++ b/config/modes/vehicles/denza_n7/meditation_space.yaml
@@ -0,0 +1,84 @@
+id: meditation_space
+name: 冥想空间模式
+aliases:
+ - 冥想空间
+ - 冥想
+description: 腾势 N7 冥想空间姿态,并联动当前已接入的恒温、低风、内循环、遮阳帘和冥想氛围灯能力。
+icon: leaf
+scope:
+ vehicle_ids:
+ - denza_n7
+operations:
+ - domain: seat
+ capability: seat.set_longitudinal_position
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 0
+ reason: 冥想空间模式水平电机目标 0mm。
+ - domain: seat
+ capability: seat.set_cushion_tilt
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 44
+ reason: 冥想空间模式坐盆电机目标。
+ - domain: seat
+ capability: seat.set_backrest_recline
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 44
+ angle_deg: 125
+ reason: 冥想空间模式靠背电机目标 30 度,按腾势 N7 显示夹角换算。
+ - domain: seat
+ capability: seat.set_legrest_height
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 54
+ angle_deg: 137
+ reason: 冥想空间模式腿托电机目标 42 度,按腾势 N7 显示夹角换算。
+ - domain: climate
+ capability: climate.set_temperature
+ target_scope: vehicle
+ operation: set
+ parameters:
+ temperature_c: 23
+ reason: 恒温 22-24 度中值。
+ - domain: climate
+ capability: climate.set_fan_level
+ target_scope: vehicle
+ operation: set
+ parameters:
+ level: 1
+ reason: 低柔风量。
+ - domain: climate
+ capability: climate.set_cycle_mode
+ target_scope: vehicle
+ operation: set
+ parameters:
+ mode: internal
+ reason: 自动切换内循环。
+ - domain: roof
+ capability: roof.set_shade_position
+ target_scope: vehicle
+ operation: set
+ parameters:
+ position_pct: 0
+ reason: 全景天幕遮阳帘完全闭合遮光。
+ - domain: ambient_light
+ capability: ambient_light.set_mode
+ target_scope: vehicle
+ operation: set
+ parameters:
+ mode: meditation
+ reason: 切换矩阵定义的冥想氛围灯模式。
+ - domain: ambient_light
+ capability: ambient_light.set_illumination
+ target_scope: vehicle
+ operation: set
+ parameters:
+ color_hex: "#6FAF9D"
+ brightness_pct: 30
+ reason: 冥想柔光亮度约为日常亮度 30%。
diff --git a/config/modes/vehicles/denza_n7/nap.yaml b/config/modes/vehicles/denza_n7/nap.yaml
new file mode 100644
index 0000000..fa958d9
--- /dev/null
+++ b/config/modes/vehicles/denza_n7/nap.yaml
@@ -0,0 +1,75 @@
+id: nap
+name: 小憩模式
+aliases:
+ - 小憩
+description: 腾势 N7 副驾小憩姿态,并联动当前已接入的静音恒温、遮阳帘和氛围灯关闭能力。
+icon: moon
+scope:
+ vehicle_ids:
+ - denza_n7
+operations:
+ - domain: seat
+ capability: seat.set_longitudinal_position
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 100
+ reason: 小憩模式水平电机目标 215mm,按腾势 N7 215mm 行程换算。
+ - domain: seat
+ capability: seat.set_cushion_tilt
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 0
+ reason: 小憩模式坐盆电机目标。
+ - domain: seat
+ capability: seat.set_backrest_recline
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 0
+ angle_deg: 95
+ reason: 小憩模式靠背电机目标 0 度,按腾势 N7 显示夹角换算。
+ - domain: seat
+ capability: seat.set_legrest_height
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 0
+ angle_deg: 95
+ reason: 小憩模式腿托电机目标 0 度,按腾势 N7 显示夹角换算。
+ - domain: climate
+ capability: climate.set_temperature
+ target_scope: vehicle
+ operation: set
+ parameters:
+ temperature_c: 23
+ reason: 自动设定 22-24 度恒温中值。
+ - domain: climate
+ capability: climate.set_fan_level
+ target_scope: vehicle
+ operation: set
+ parameters:
+ level: 1
+ reason: 低风静音档位。
+ - domain: climate
+ capability: climate.set_cycle_mode
+ target_scope: vehicle
+ operation: set
+ parameters:
+ mode: internal
+ reason: 自动切换内循环。
+ - domain: roof
+ capability: roof.set_shade_position
+ target_scope: vehicle
+ operation: set
+ parameters:
+ position_pct: 0
+ reason: 全景天幕遮阳帘关闭。
+ - domain: ambient_light
+ capability: ambient_light.set_power
+ target_scope: vehicle
+ operation: set
+ parameters:
+ state: "off"
+ reason: 氛围灯关闭,阅读灯等独立灯具暂未接入。
diff --git a/config/modes/vehicles/denza_n7/office.yaml b/config/modes/vehicles/denza_n7/office.yaml
new file mode 100644
index 0000000..6178c5f
--- /dev/null
+++ b/config/modes/vehicles/denza_n7/office.yaml
@@ -0,0 +1,83 @@
+id: office
+name: 办公模式
+aliases:
+ - 办公
+description: 腾势 N7 副驾办公姿态,并联动当前已接入的恒温、中低风、内循环、遮阳帘和冷白氛围灯能力。
+icon: briefcase
+scope:
+ vehicle_ids:
+ - denza_n7
+operations:
+ - domain: seat
+ capability: seat.set_longitudinal_position
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 28
+ reason: 办公模式水平电机目标 60mm,按腾势 N7 215mm 行程换算。
+ - domain: seat
+ capability: seat.set_cushion_tilt
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 22
+ reason: 办公模式坐盆电机目标。
+ - domain: seat
+ capability: seat.set_backrest_recline
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 22
+ angle_deg: 110
+ reason: 办公模式靠背电机目标 15 度,按腾势 N7 显示夹角换算。
+ - domain: seat
+ capability: seat.set_legrest_height
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 19
+ angle_deg: 110
+ reason: 办公模式腿托电机目标 15 度,按腾势 N7 显示夹角换算。
+ - domain: climate
+ capability: climate.set_temperature
+ target_scope: passenger
+ operation: set
+ parameters:
+ temperature_c: 24.5
+ reason: 办公座位专属恒温 24.5 度。
+ - domain: climate
+ capability: climate.set_fan_level
+ target_scope: vehicle
+ operation: set
+ parameters:
+ level: 3
+ reason: 中档静音风量。
+ - domain: climate
+ capability: climate.set_cycle_mode
+ target_scope: vehicle
+ operation: set
+ parameters:
+ mode: internal
+ reason: 自动切换内循环。
+ - domain: climate
+ capability: climate.set_outlet_mode
+ target_scope: passenger
+ operation: set
+ parameters:
+ mode: face
+ reason: 水平上吹近似映射为当前能力支持的面部出风。
+ - domain: roof
+ capability: roof.set_shade_position
+ target_scope: vehicle
+ operation: set
+ parameters:
+ position_pct: 30
+ reason: 全景天幕遮阳帘 70% 半关闭,换算为 30% 打开。
+ - domain: ambient_light
+ capability: ambient_light.set_illumination
+ target_scope: vehicle
+ operation: set
+ parameters:
+ color_hex: "#E8F3FF"
+ brightness_pct: 70
+ reason: 办公冷白光氛围。
diff --git a/config/modes/vehicles/denza_n7/queen_passenger_driving.yaml b/config/modes/vehicles/denza_n7/queen_passenger_driving.yaml
new file mode 100644
index 0000000..72536e4
--- /dev/null
+++ b/config/modes/vehicles/denza_n7/queen_passenger_driving.yaml
@@ -0,0 +1,69 @@
+id: queen_passenger_driving
+name: 女王副驾(行驶)
+aliases:
+ - 女王副驾
+ - 女王副驾模式
+description: 腾势 N7 女王副驾行驶姿态,并联动当前已接入的副驾恒温、低风、内循环和出风能力。
+icon: crown
+scope:
+ vehicle_ids:
+ - denza_n7
+operations:
+ - domain: seat
+ capability: seat.set_longitudinal_position
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 28
+ reason: 女王副驾水平电机目标 60mm,按腾势 N7 215mm 行程换算。
+ - domain: seat
+ capability: seat.set_cushion_tilt
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 38
+ reason: 女王副驾坐盆电机目标 37.5%,按整数百分比四舍五入。
+ - domain: seat
+ capability: seat.set_backrest_recline
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 44
+ angle_deg: 125
+ reason: 女王副驾靠背电机目标 30 度,按腾势 N7 显示夹角换算。
+ - domain: seat
+ capability: seat.set_legrest_height
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 28
+ angle_deg: 117
+ reason: 女王副驾腿托电机目标 22 度,按腾势 N7 显示夹角换算。
+ - domain: climate
+ capability: climate.set_temperature
+ target_scope: passenger
+ operation: set
+ parameters:
+ temperature_c: 23.5
+ reason: 副驾恒温锁定 23.5 度。
+ - domain: climate
+ capability: climate.set_fan_level
+ target_scope: vehicle
+ operation: set
+ parameters:
+ level: 1
+ reason: 风量强制最低静音 1 档。
+ - domain: climate
+ capability: climate.set_cycle_mode
+ target_scope: vehicle
+ operation: set
+ parameters:
+ mode: internal
+ reason: 自动切换内循环。
+ - domain: climate
+ capability: climate.set_outlet_mode
+ target_scope: passenger
+ operation: set
+ parameters:
+ mode: face
+ reason: 副驾水平上吹近似映射为当前能力支持的面部出风。
diff --git a/config/modes/vehicles/denza_n7/zero_gravity.yaml b/config/modes/vehicles/denza_n7/zero_gravity.yaml
new file mode 100644
index 0000000..0064998
--- /dev/null
+++ b/config/modes/vehicles/denza_n7/zero_gravity.yaml
@@ -0,0 +1,43 @@
+id: zero_gravity
+name: 零重力模式
+aliases:
+ - 零重力
+ - 零重力模式
+ - zero gravity
+ - zero-gravity
+description: 副驾零重力展示姿态,使用腾势 N7 当前座椅角度标定生成座椅 CAN 目标。
+icon: orbit
+scope:
+ vehicle_ids:
+ - denza_n7
+operations:
+ - domain: seat
+ capability: seat.set_longitudinal_position
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 0
+ reason: 零重力模式水平电机目标 0mm,按腾势 N7 215mm 行程换算。
+ - domain: seat
+ capability: seat.set_cushion_tilt
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 100
+ reason: 零重力模式坐盆电机目标。
+ - domain: seat
+ capability: seat.set_backrest_recline
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 81
+ angle_deg: 150
+ reason: 零重力模式靠背电机目标 55 度,按腾势 N7 显示夹角换算。
+ - domain: seat
+ capability: seat.set_legrest_height
+ target_scope: passenger
+ operation: set
+ parameters:
+ position_pct: 87
+ angle_deg: 163
+ reason: 零重力模式腿托电机目标 68 度,按腾势 N7 显示夹角换算。
diff --git a/config/vehicles/denza_n7.yaml b/config/vehicles/denza_n7.yaml
new file mode 100644
index 0000000..e0407f5
--- /dev/null
+++ b/config/vehicles/denza_n7.yaml
@@ -0,0 +1,42 @@
+id: denza_n7
+name: 腾势 N7
+seat:
+ motors:
+ longitudinal:
+ name: 水平
+ unit: mm
+ range: 215
+ reference_zero: rearmost
+ positive_direction: forward
+ hall_total_pulses: 480
+ cushion_tilt:
+ name: 坐盆
+ unit: percent
+ range: 100
+ reference_zero: lowest
+ positive_direction: up
+ hall_total_pulses: 374
+ backrest_recline:
+ name: 座椅靠背
+ unit: degree
+ range: 68
+ reference_zero: upright
+ positive_direction: recline
+ hall_total_pulses: 917
+ display_angle:
+ kind: included_angle
+ zero_deg: 95
+ max_deg: 163
+ provisional: true
+ legrest_height:
+ name: 腿托
+ unit: degree
+ range: 78
+ reference_zero: retracted
+ positive_direction: raise
+ hall_total_pulses: 647
+ display_angle:
+ kind: included_angle
+ zero_deg: 95
+ max_deg: 173
+ provisional: true
diff --git a/docs/superpowers/plans/2026-07-30-mode-profile-library.md b/docs/superpowers/plans/2026-07-30-mode-profile-library.md
new file mode 100644
index 0000000..d7fd025
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-30-mode-profile-library.md
@@ -0,0 +1,26 @@
+# Mode Profile Library Plan
+
+## Goal
+
+Add a long-term cabin mode configuration layer that can be matched from natural-language requests before normal CAN compilation.
+
+## Scope
+
+- Add repository-backed mode YAML files under `config/modes/`.
+- Convert matched mode operations into the same model-plan requests already used by seat, climate, roof, and ambient-light adapters.
+- Preserve current behavior for unmatched or extra user goals by letting the AI/local parser generate temporary requests.
+- Allow a recent successful action set to be saved as a long-term mode profile when the user explicitly asks to solidify a mode.
+- Expose available modes to the web console and wire the existing quick-mode/save controls to the new workflow.
+
+## Tests First
+
+- Unit-test mode profile loading, matching, request conversion, and save-to-YAML behavior.
+- Integration-test zero-gravity matching alone.
+- Integration-test zero-gravity plus a temporary roof request producing a cross-domain `SceneConfig`.
+- Integration-test saving the previous successful action set as a long-term mode file without emitting CAN.
+
+## Design Notes
+
+- The mode library stores semantic operations, not CAN frames. CAN remains generated by the existing domain compilers.
+- Seat-related operations in modes still pass through vehicle-profile angle/percentage calibration.
+- Save-mode detection is intentionally narrower than seat posture preset saving to avoid changing existing preset behavior.
diff --git a/docs/superpowers/plans/2026-07-30-vehicle-profile-seat-calibration.md b/docs/superpowers/plans/2026-07-30-vehicle-profile-seat-calibration.md
new file mode 100644
index 0000000..d4999e7
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-30-vehicle-profile-seat-calibration.md
@@ -0,0 +1,126 @@
+# Vehicle Profile Seat Calibration Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add vehicle-selectable Denza N7 seat calibration so natural-language physical deltas can be deterministically converted to absolute seat `position_pct` before CAN compilation.
+
+**Architecture:** Add a small vehicle profile layer under `src/cognitive_agent/application/` that loads YAML profiles from `config/vehicles/`. `SeatDemoService` passes the selected profile into model-plan adaptation and returns available profiles via `/api/runtime`; the frontend stores a selected vehicle id and includes it in `/api/requests`. CAN compilers remain unchanged and continue receiving absolute `position_pct`.
+
+**Tech Stack:** Python 3.11 stdlib + PyYAML, existing unittest suite, existing static HTML/JS frontend.
+
+---
+
+### Task 1: Vehicle Profile Loader
+
+**Files:**
+- Create: `config/vehicles/denza_n7.yaml`
+- Create: `src/cognitive_agent/application/vehicle_profile.py`
+- Test: `tests/unit/test_vehicle_profile.py`
+
+- [ ] **Step 1: Write failing tests**
+
+Create `tests/unit/test_vehicle_profile.py` with tests that load the Denza N7 profile, expose all four motors, convert relative physical deltas into target percentages, and expose angle ranges for frontend display.
+
+- [ ] **Step 2: Run tests and verify RED**
+
+Run: `PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src:domains/seat/src .omx/python311-venv/bin/python -m unittest tests.unit.test_vehicle_profile -v`
+
+Expected: fails because `cognitive_agent.application.vehicle_profile` does not exist.
+
+- [ ] **Step 3: Implement loader**
+
+Create the Denza N7 YAML profile with `longitudinal`, `cushion_tilt`, `backrest_recline`, and `legrest_height` motors. Implement `VehicleProfileRegistry`, `VehicleProfile`, and `SeatMotorProfile` with methods to compute clamped absolute targets from relative deltas.
+
+- [ ] **Step 4: Run tests and verify GREEN**
+
+Run the same unittest command and expect PASS.
+
+### Task 2: Apply Vehicle Profile During Intent Adaptation
+
+**Files:**
+- Modify: `src/cognitive_agent/application/model_plan_adapter.py`
+- Test: `tests/unit/test_vehicle_profile.py`
+
+- [ ] **Step 1: Write failing tests**
+
+Add tests showing a request with `distance_mm=50` and `operation=increase` for `seat.set_longitudinal_position` converts from current 50% to 73% using Denza N7's 215mm range. Add a test showing `angle_delta_deg=10` for `seat.set_backrest_recline` converts from 20% to 38% using the 55 degree range and preserves `motion_intent`.
+
+- [ ] **Step 2: Run tests and verify RED**
+
+Run the vehicle profile unittest and expect failure because `ModelPlanAdapter.adapt()` does not accept a vehicle profile and rejects unknown `distance_mm` / `angle_delta_deg` parameters.
+
+- [ ] **Step 3: Implement adaptation**
+
+Allow vehicle-profile-backed physical delta parameters for seat motion requests. Translate them to absolute `position_pct`, preserve relative motion metadata, and keep the compiler-facing `FunctionConfig` unchanged.
+
+- [ ] **Step 4: Run tests and verify GREEN**
+
+Run the vehicle profile unittest and expect PASS.
+
+### Task 3: Thread Vehicle Selection Through API and Runtime
+
+**Files:**
+- Modify: `src/cognitive_agent/application/seat_service.py`
+- Modify: `src/cognitive_agent/server.py`
+- Test: `tests/e2e/test_seat_web_api.py`
+
+- [ ] **Step 1: Write failing tests**
+
+Add e2e coverage that `/api/runtime` includes `vehicles` and `default_vehicle_id`, and that a POST containing `vehicle_id: "denza_n7"` records the selected profile in the result and uses it for conversion.
+
+- [ ] **Step 2: Run tests and verify RED**
+
+Run: `PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src:domains/seat/src .omx/python311-venv/bin/python -m unittest tests.e2e.test_seat_web_api -v`
+
+Expected: fails because runtime has no vehicles and POST ignores `vehicle_id`.
+
+- [ ] **Step 3: Implement API threading**
+
+Instantiate `VehicleProfileRegistry` in `SeatDemoService`, return public profiles from `runtime_info()`, accept `vehicle_id` in `process()`, pass the selected profile to `ModelPlanAdapter`, and include `vehicle_id` / `vehicle_name` in the API result. Update `server.py` to read `vehicle_id` from JSON.
+
+- [ ] **Step 4: Run tests and verify GREEN**
+
+Run the e2e unittest and expect PASS.
+
+### Task 4: Add Frontend Vehicle Selector
+
+**Files:**
+- Modify: `apps/web-console/index.html`
+- Modify: `apps/web-console/app.js`
+- Modify: `apps/web-console/styles.css`
+- Test: `tests/e2e/test_seat_web_api.py`
+
+- [ ] **Step 1: Write failing tests**
+
+Extend the static HTML/runtime e2e test to assert that the page has a vehicle selector and that runtime-provided vehicles are expected by the frontend.
+
+- [ ] **Step 2: Run tests and verify RED**
+
+Run the e2e unittest and expect failure because the selector does not exist.
+
+- [ ] **Step 3: Implement UI**
+
+Add a compact selector in the top bar or command panel, populate it from `/api/runtime.vehicles`, keep `denza_n7` selected by default, store the selected id in `state.vehicleId`, and include `vehicle_id` in POST `/api/requests`.
+
+- [ ] **Step 4: Run tests and verify GREEN**
+
+Run the e2e unittest and expect PASS.
+
+### Task 5: Verification
+
+**Files:**
+- No new production files beyond Tasks 1-4.
+
+- [ ] **Step 1: Run targeted tests**
+
+Run:
+`PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src:domains/seat/src .omx/python311-venv/bin/python -m unittest tests.unit.test_vehicle_profile domains.seat.tests.unit.test_t2g_hardware tests.e2e.test_seat_web_api -v`
+
+- [ ] **Step 2: Smoke running service**
+
+Use the existing Python 3.11 venv to GET `/api/runtime` and verify Denza N7 appears. POST a local/direct request with `vehicle_id=denza_n7` and confirm a successful dry-run still returns a `CommandPlan`.
+
+- [ ] **Step 3: Review diff**
+
+Run `git diff --stat` and inspect changed files. Do not revert the pre-existing `README.md` change unless explicitly requested.
+
diff --git a/domains/seat/config/capabilities.yaml b/domains/seat/config/capabilities.yaml
index a99a9ce..6665223 100755
--- a/domains/seat/config/capabilities.yaml
+++ b/domains/seat/config/capabilities.yaml
@@ -65,7 +65,9 @@ spec:
description: >-
Set one front seat's fore-aft rail position. position_pct is an absolute target: 0 is the
rearmost mechanical limit and 100 is the forwardmost limit. Resolve relative requests such
- as "move back a little" against the current position supplied in runtime state.
+ as "move back a little" against the current position supplied in runtime state. For a
+ vehicle-specific physical relative request, distance_mm may be supplied and the runtime
+ converts it to an absolute position_pct before CAN compilation.
target_devices:
- seat_longitudinal_motor
target_scopes:
@@ -75,10 +77,17 @@ spec:
- name: position_pct
type: integer
unit: percent
- required: true
+ required: false
minimum: 0
maximum: 100
description: Absolute normalized fore-aft target.
+ - name: distance_mm
+ type: number
+ unit: mm
+ required: false
+ minimum: 0
+ maximum: 215
+ description: Relative physical fore-aft movement resolved against current position and vehicle calibration.
risk_level: high
confirmation_policy: on_ambiguity
- name: seat.set_cushion_tilt
@@ -105,7 +114,9 @@ spec:
Set the backrest recline of one front seat. Use either position_pct or angle_deg.
position_pct is absolute: 0 is the most upright limit and 100 is the most reclined limit.
angle_deg is the included angle between the seat-cushion surface and backrest centreline,
- from 95 to 140 degrees. "Backrest backward" means a larger value.
+ from 95 to 163 degrees for the selected Denza N7 profile. "Backrest backward" means a
+ larger value. For relative physical angle requests, angle_delta_deg may be supplied and the
+ runtime converts it to an absolute position_pct before CAN compilation.
target_devices:
- seat_backrest_motor
target_scopes:
@@ -124,16 +135,25 @@ spec:
unit: degree
required: false
minimum: 95
- maximum: 140
+ maximum: 163
description: Exact included angle between the cushion surface and backrest centreline.
+ - name: angle_delta_deg
+ type: number
+ unit: degree
+ required: false
+ minimum: 0
+ maximum: 68
+ description: Relative backrest angle movement resolved against current posture and vehicle calibration.
risk_level: high
confirmation_policy: on_ambiguity
- name: seat.set_legrest_height
description: >-
Raise or lower one front seat's powered leg rest. Use either position_pct or angle_deg.
position_pct is absolute: 0 is fully lowered and 100 is fully raised. angle_deg is the
- included angle between the cushion surface and leg-rest surface, from 95 to 160 degrees.
- This axis does not change leg-rest extension.
+ included angle between the cushion surface and leg-rest surface, from 95 to 173 degrees for
+ the selected Denza N7 profile. For relative physical angle requests, angle_delta_deg may be
+ supplied and the runtime converts it to an absolute position_pct before CAN compilation. This
+ axis does not change leg-rest extension.
target_devices:
- seat_legrest_lift_motor
target_scopes:
@@ -152,8 +172,15 @@ spec:
unit: degree
required: false
minimum: 95
- maximum: 160
+ maximum: 173
description: Exact included angle between the cushion surface and leg-rest surface.
+ - name: angle_delta_deg
+ type: number
+ unit: degree
+ required: false
+ minimum: 0
+ maximum: 78
+ description: Relative leg-rest lift angle movement resolved against current posture and vehicle calibration.
risk_level: high
confirmation_policy: on_ambiguity
- name: seat.set_legrest_extension
diff --git a/domains/seat/schemas/function-parameters.schema.json b/domains/seat/schemas/function-parameters.schema.json
index 5ee58b9..79a4322 100755
--- a/domains/seat/schemas/function-parameters.schema.json
+++ b/domains/seat/schemas/function-parameters.schema.json
@@ -61,7 +61,7 @@
"required": ["position_pct"],
"properties": {
"position_pct": {"type": "integer", "minimum": 0, "maximum": 100},
- "angle_deg": {"type": "number", "minimum": 95, "maximum": 160}
+ "angle_deg": {"type": "number", "minimum": 95, "maximum": 173}
}
}
}
diff --git a/domains/seat/src/seat_domain/validator.py b/domains/seat/src/seat_domain/validator.py
index 0d40986..3867408 100755
--- a/domains/seat/src/seat_domain/validator.py
+++ b/domains/seat/src/seat_domain/validator.py
@@ -16,8 +16,8 @@ SUPPORTED_MOTION_AXES = {
"legrest_extension",
}
ANGLE_RANGES = {
- "backrest_recline": (95, 140),
- "legrest_height": (95, 160),
+ "backrest_recline": (95, 163),
+ "legrest_height": (95, 173),
}
diff --git a/domains/seat/tests/integration/test_seat_service.py b/domains/seat/tests/integration/test_seat_service.py
index 9265fe9..32b06ee 100755
--- a/domains/seat/tests/integration/test_seat_service.py
+++ b/domains/seat/tests/integration/test_seat_service.py
@@ -376,6 +376,105 @@ class SeatServiceIntegrationTests(unittest.TestCase):
40,
)
+ def test_model_relative_physical_motion_uses_vehicle_profile(self) -> None:
+ model_output = {
+ "detected_domains": ["seat"],
+ "requests": [
+ {
+ "domain": "seat",
+ "capability": "seat.set_longitudinal_position",
+ "target_scope": "driver",
+ "operation": "increase",
+ "parameters": [{"name": "distance_mm", "value": 50}],
+ "reason": "Move forward by a physical distance.",
+ },
+ {
+ "domain": "seat",
+ "capability": "seat.set_backrest_recline",
+ "target_scope": "driver",
+ "operation": "increase",
+ "parameters": [{"name": "angle_delta_deg", "value": 10}],
+ "reason": "Recline by a relative physical angle.",
+ },
+ ],
+ "ambiguities": [],
+ "decision_summary": ["使用车型量程把相对物理量换算为绝对百分比。"],
+ "confidence": 0.94,
+ }
+ self.service.openai.api_key = "test-key"
+
+ with patch.object(self.service.openai, "interpret", return_value=model_output) as interpret:
+ result = self.service.process(
+ "主驾水平向前移动50毫米,靠背往后10度",
+ "openai",
+ "conv-vehicle-profile-relative",
+ vehicle_id="denza_n7",
+ )
+
+ self.assertEqual(result["status"], "succeeded")
+ self.assertEqual(result["vehicle"]["id"], "denza_n7")
+ operations = result["function_config"]["spec"]["operations"]
+ self.assertEqual(operations[0]["parameters"], {"position_pct": 73})
+ self.assertEqual(
+ operations[1]["parameters"],
+ {"position_pct": 38, "angle_deg": 116},
+ )
+ backrest_motion = operations[1]["motion"]
+ self.assertEqual(backrest_motion["mode"], "relative")
+ self.assertEqual(backrest_motion["direction"], "increase")
+ self.assertEqual(backrest_motion["start_position_pct"], 20)
+ self.assertEqual(backrest_motion["delta_position_pct"], 18)
+ self.assertEqual(backrest_motion["start_angle_deg"], 106.0)
+ self.assertEqual(backrest_motion["delta_angle_deg"], 10)
+ context_sent_to_model = interpret.call_args.args[1]
+ self.assertEqual(context_sent_to_model["vehicle"]["id"], "denza_n7")
+ self.assertEqual(
+ context_sent_to_model["devices"]["driver"]["position"][
+ "backrest_recline"
+ ]["angle_deg"],
+ 106.0,
+ )
+
+ def test_vehicle_profile_angle_limits_are_accepted(self) -> None:
+ model_output = {
+ "detected_domains": ["seat"],
+ "requests": [
+ {
+ "domain": "seat",
+ "capability": "seat.set_backrest_recline",
+ "target_scope": "passenger",
+ "operation": "set",
+ "parameters": [{"name": "angle_deg", "value": 163}],
+ "reason": "Use the vehicle-specific maximum backrest angle.",
+ },
+ {
+ "domain": "seat",
+ "capability": "seat.set_legrest_height",
+ "target_scope": "passenger",
+ "operation": "set",
+ "parameters": [{"name": "angle_deg", "value": 173}],
+ "reason": "Use the vehicle-specific maximum legrest angle.",
+ },
+ ],
+ "ambiguities": [],
+ "decision_summary": ["车型角度边界按腾势 N7 配置执行。"],
+ "confidence": 0.95,
+ }
+ self.service.openai.api_key = "test-key"
+
+ with patch.object(self.service.openai, "interpret", return_value=model_output):
+ result = self.service.process(
+ "副驾靠背到163度,腿托到173度",
+ "openai",
+ "conv-vehicle-angle-limits",
+ vehicle_id="denza_n7",
+ )
+
+ self.assertEqual(result["status"], "succeeded")
+ operations = result["function_config"]["spec"]["operations"]
+ self.assertEqual(operations[0]["parameters"], {"position_pct": 100, "angle_deg": 163})
+ self.assertEqual(operations[1]["parameters"], {"position_pct": 100, "angle_deg": 173})
+
def test_motion_adapter_does_not_resolve_missing_absolute_target(self) -> None:
model_output = {
"detected_domains": ["seat"],
@@ -527,11 +626,11 @@ class SeatServiceIntegrationTests(unittest.TestCase):
first_operations = first["function_config"]["spec"]["operations"]
self.assertEqual(
first_operations[0]["parameters"],
- {"position_pct": 73, "angle_deg": 128},
+ {"position_pct": 49, "angle_deg": 128},
)
self.assertEqual(
first_operations[1]["parameters"],
- {"position_pct": 58, "angle_deg": 133},
+ {"position_pct": 49, "angle_deg": 133},
)
self.assertEqual(
first["actuator_state"]["passenger"]["position"]["backrest_recline"][
@@ -541,7 +640,7 @@ class SeatServiceIntegrationTests(unittest.TestCase):
)
first_telemetry = first["execution_report"]["spec"]["results"][0]["telemetry"]
self.assertEqual(first_telemetry["requested_angle_deg"], 128)
- self.assertEqual(first_telemetry["effective_position_pct"], 73)
+ self.assertEqual(first_telemetry["effective_position_pct"], 49)
self.assertEqual(
second["actuator_state"]["passenger"]["position"]["backrest_recline"][
@@ -554,8 +653,8 @@ class SeatServiceIntegrationTests(unittest.TestCase):
]
self.assertEqual(relative_motion["mode"], "relative")
self.assertEqual(relative_motion["direction"], "increase")
- self.assertEqual(relative_motion["start_position_pct"], 73)
- self.assertEqual(relative_motion["delta_position_pct"], 11)
+ self.assertEqual(relative_motion["start_position_pct"], 49)
+ self.assertEqual(relative_motion["delta_position_pct"], 7)
self.assertEqual(relative_motion["delta_angle_deg"], 5)
second_context = interpret.call_args_list[1].args[1]
self.assertEqual(
diff --git a/domains/seat/tests/unit/test_t2g_hardware.py b/domains/seat/tests/unit/test_t2g_hardware.py
index e45216a..9a886d8 100755
--- a/domains/seat/tests/unit/test_t2g_hardware.py
+++ b/domains/seat/tests/unit/test_t2g_hardware.py
@@ -598,6 +598,11 @@ class T2GHardwareTests(unittest.TestCase):
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "true",
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN": "bench-secret",
"COGNITIVE_AGENT_SEAT_FIRMWARE_PROFILES": "combined",
+ "COGNITIVE_AGENT_SEAT_CAN_BACKEND": "python-can",
+ "COGNITIVE_AGENT_SEAT_CAN_BITRATE": "500000",
+ "COGNITIVE_AGENT_SEAT_CAN_INTERFACE": "socketcan",
+ "COGNITIVE_AGENT_SEAT_CAN_CHANNEL": "0",
+ "COGNITIVE_AGENT_SEAT_AUTO_FLASH_ENABLED": "false",
"COGNITIVE_AGENT_ENABLE_UNVERIFIED_LONGITUDINAL": "false",
}
with patch.dict(os.environ, environment, clear=False):
diff --git a/src/cognitive_agent/application/conversation_state.py b/src/cognitive_agent/application/conversation_state.py
index 0c3e10a..9474353 100755
--- a/src/cognitive_agent/application/conversation_state.py
+++ b/src/cognitive_agent/application/conversation_state.py
@@ -339,6 +339,55 @@ class ConversationStateStore:
self._persist_presets(conversation_id, state["presets"])
return deepcopy(state)
+ def save_mode_profile(
+ self,
+ conversation_id: str,
+ utterance: str,
+ mode_profile: dict[str, Any],
+ source_actions: list[dict[str, Any]],
+ decision_summary: list[str],
+ ) -> dict[str, Any]:
+ with self._lock:
+ state = self._state(conversation_id)
+ state["turn_count"] += 1
+ state["updated_at"] = _now()
+ state["recent_turns"].append(
+ {
+ "utterance": utterance,
+ "actions": deepcopy(source_actions),
+ "decision_summary": list(decision_summary),
+ "control_event": "mode_profile_save",
+ "mode_profile": deepcopy(mode_profile),
+ }
+ )
+ state["recent_turns"] = state["recent_turns"][-6:]
+ return deepcopy(state)
+
+ def record_mode_profile_event(
+ self,
+ conversation_id: str,
+ utterance: str,
+ mode_profile: dict[str, Any],
+ source_actions: list[dict[str, Any]],
+ decision_summary: list[str],
+ control_event: str,
+ ) -> dict[str, Any]:
+ with self._lock:
+ state = self._state(conversation_id)
+ state["turn_count"] += 1
+ state["updated_at"] = _now()
+ state["recent_turns"].append(
+ {
+ "utterance": utterance,
+ "actions": deepcopy(source_actions),
+ "decision_summary": list(decision_summary),
+ "control_event": control_event,
+ "mode_profile": deepcopy(mode_profile),
+ }
+ )
+ state["recent_turns"] = state["recent_turns"][-6:]
+ return deepcopy(state)
+
def reset(self, conversation_id: str) -> dict[str, Any]:
with self._lock:
state = _new_state(conversation_id, self._load_presets(conversation_id))
diff --git a/src/cognitive_agent/application/mode_profile.py b/src/cognitive_agent/application/mode_profile.py
new file mode 100644
index 0000000..33c692f
--- /dev/null
+++ b/src/cognitive_agent/application/mode_profile.py
@@ -0,0 +1,435 @@
+"""Repository-backed cabin mode profiles."""
+
+from __future__ import annotations
+
+import hashlib
+import re
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+
+_WHITESPACE = re.compile(r"\s+")
+_NON_ASCII_SLUG = re.compile(r"[^a-z0-9]+")
+
+
+def _normalize_match_text(value: str) -> str:
+ return _WHITESPACE.sub("", value.casefold())
+
+
+def _clean_name(value: str) -> str:
+ cleaned = value.strip().strip(",。,.!!")
+ if not cleaned:
+ raise ValueError("mode name must not be empty")
+ if len(cleaned) > 64:
+ raise ValueError("mode name must be at most 64 characters")
+ return cleaned
+
+
+def _mode_aliases(name: str, aliases: list[str] | None = None) -> list[str]:
+ values = [name, *(aliases or [])]
+ if name.endswith("模式") and len(name) > 2:
+ values.append(name.removesuffix("模式"))
+ else:
+ values.append(f"{name}模式")
+
+ normalized: set[str] = set()
+ unique = []
+ for value in values:
+ candidate = str(value).strip()
+ key = _normalize_match_text(candidate)
+ if not candidate or key in normalized:
+ continue
+ normalized.add(key)
+ unique.append(candidate)
+ return unique
+
+
+def _slugify_mode_id(name: str) -> str:
+ ascii_slug = _NON_ASCII_SLUG.sub("-", name.casefold()).strip("-")
+ digest = hashlib.sha256(name.encode("utf-8")).hexdigest()[:10]
+ if ascii_slug:
+ return ascii_slug if len(ascii_slug) >= 3 else f"{ascii_slug}-{digest[:6]}"
+ return f"mode-{digest}"
+
+
+@dataclass(frozen=True)
+class ModeOperation:
+ domain: str
+ capability: str
+ target_scope: str
+ parameters: dict[str, Any]
+ operation: str = "set"
+ reason: str = ""
+
+ @classmethod
+ def from_dict(cls, document: dict[str, Any]) -> "ModeOperation":
+ parameters = document.get("parameters", {})
+ if not isinstance(parameters, dict):
+ raise ValueError("mode operation parameters must be an object")
+ return cls(
+ domain=str(document["domain"]),
+ capability=str(document["capability"]),
+ target_scope=str(document["target_scope"]),
+ operation=str(document.get("operation", "set")),
+ parameters=dict(parameters),
+ reason=str(document.get("reason", "")),
+ )
+
+ def to_request(self, mode_name: str) -> dict[str, Any]:
+ return {
+ "domain": self.domain,
+ "capability": self.capability,
+ "target_scope": self.target_scope,
+ "operation": self.operation,
+ "parameters": [
+ {"name": name, "value": value}
+ for name, value in self.parameters.items()
+ ],
+ "reason": self.reason or f"Matched mode profile {mode_name}.",
+ }
+
+ def to_yaml(self) -> dict[str, Any]:
+ return {
+ "domain": self.domain,
+ "capability": self.capability,
+ "target_scope": self.target_scope,
+ "operation": self.operation,
+ "parameters": dict(self.parameters),
+ "reason": self.reason,
+ }
+
+
+@dataclass(frozen=True)
+class ModeProfile:
+ id: str
+ name: str
+ aliases: tuple[str, ...]
+ description: str
+ icon: str
+ operations: tuple[ModeOperation, ...]
+ source: str
+ common: bool = True
+ vehicle_ids: tuple[str, ...] = ()
+
+ @classmethod
+ def from_yaml(cls, path: Path, profile_root: Path | None = None) -> "ModeProfile":
+ document = yaml.safe_load(path.read_text(encoding="utf-8"))
+ if not isinstance(document, dict):
+ raise ValueError(f"{path} must contain a YAML object")
+ operations = document.get("operations", [])
+ if not isinstance(operations, list) or not operations:
+ raise ValueError(f"{path} must contain at least one operation")
+ name = _clean_name(str(document["name"]))
+ common, vehicle_ids = _mode_scope(document, path, profile_root)
+ aliases = _mode_aliases(
+ name,
+ [str(alias) for alias in document.get("aliases", [])],
+ )
+ return cls(
+ id=str(document["id"]),
+ name=name,
+ aliases=tuple(aliases),
+ description=str(document.get("description", "")),
+ icon=str(document.get("icon", "bookmark")),
+ operations=tuple(ModeOperation.from_dict(item) for item in operations),
+ source=str(path),
+ common=common,
+ vehicle_ids=vehicle_ids,
+ )
+
+ def public_dict(self) -> dict[str, Any]:
+ return {
+ "id": self.id,
+ "name": self.name,
+ "aliases": list(self.aliases),
+ "description": self.description,
+ "icon": self.icon,
+ "domains": sorted({operation.domain for operation in self.operations}),
+ "operation_count": len(self.operations),
+ "scope": {
+ "common": self.common,
+ "vehicle_ids": list(self.vehicle_ids),
+ },
+ }
+
+
+class ModeProfileRegistry:
+ """Load, match, and persist long-term cabin mode profiles."""
+
+ def __init__(self, profile_root: Path):
+ self.profile_root = Path(profile_root)
+ self._profiles: list[ModeProfile] = []
+ self.reload()
+
+ def reload(self) -> None:
+ if not self.profile_root.exists():
+ self._profiles = []
+ return
+ self._profiles = [
+ ModeProfile.from_yaml(path, self.profile_root)
+ for path in sorted(self.profile_root.rglob("*.yaml"))
+ if path.is_file()
+ ]
+
+ def profiles(self) -> list[ModeProfile]:
+ return list(self._profiles)
+
+ def public_modes(self, vehicle_id: str | None = None) -> list[dict[str, Any]]:
+ return [
+ profile.public_dict()
+ for profile in self._profiles_for_vehicle(vehicle_id)
+ ]
+
+ def match_text(
+ self,
+ utterance: str,
+ vehicle_id: str | None = None,
+ ) -> list[ModeProfile]:
+ haystack = _normalize_match_text(utterance)
+ selected = {
+ profile.id: profile
+ for profile in self._profiles_for_vehicle(vehicle_id)
+ }
+ matches = []
+ matched_ids: set[str] = set()
+ for profile in self._visible_candidates_for_vehicle(vehicle_id):
+ candidates = [profile.id, profile.name, *profile.aliases]
+ if any(
+ candidate
+ and _normalize_match_text(str(candidate)) in haystack
+ for candidate in candidates
+ ):
+ if profile.id in matched_ids:
+ continue
+ matched_ids.add(profile.id)
+ matches.append(selected[profile.id])
+ return matches
+
+ @staticmethod
+ def requests_for_profiles(profiles: list[ModeProfile]) -> list[dict[str, Any]]:
+ return [
+ operation.to_request(profile.name)
+ for profile in profiles
+ for operation in profile.operations
+ ]
+
+ def save_mode(
+ self,
+ name: str,
+ operations: list[dict[str, Any] | ModeOperation],
+ mode_id: str | None = None,
+ aliases: list[str] | None = None,
+ vehicle_id: str | None = None,
+ ) -> ModeProfile:
+ clean_name = _clean_name(name)
+ canonical_operations = [
+ operation
+ if isinstance(operation, ModeOperation)
+ else ModeOperation.from_dict(operation)
+ for operation in operations
+ ]
+ if not canonical_operations:
+ raise ValueError("mode profile must contain at least one operation")
+ profile_id = mode_id or _slugify_mode_id(clean_name)
+ scope = (
+ {"vehicle_ids": [vehicle_id]}
+ if vehicle_id
+ else {"common": True, "vehicle_ids": []}
+ )
+ document = {
+ "id": profile_id,
+ "name": clean_name,
+ "aliases": _mode_aliases(clean_name, aliases),
+ "description": "用户固化的长期模式配置。",
+ "icon": "bookmark",
+ "scope": scope,
+ "operations": [
+ operation.to_yaml() for operation in canonical_operations
+ ],
+ }
+ path = (
+ self.profile_root / "vehicles" / vehicle_id / "user" / f"{profile_id}.yaml"
+ if vehicle_id
+ else self.profile_root / "common" / "user" / f"{profile_id}.yaml"
+ )
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = path.with_suffix(path.suffix + ".tmp")
+ temporary.write_text(
+ yaml.safe_dump(document, allow_unicode=True, sort_keys=False),
+ encoding="utf-8",
+ )
+ temporary.replace(path)
+ self.reload()
+ return ModeProfile.from_yaml(path, self.profile_root)
+
+ def update_mode(
+ self,
+ profile: ModeProfile,
+ operations: list[dict[str, Any] | ModeOperation],
+ ) -> ModeProfile:
+ canonical_updates = _canonical_operations(operations)
+ if not canonical_updates:
+ raise ValueError("mode profile update must contain at least one operation")
+
+ path = Path(profile.source)
+ if not path.is_file():
+ raise ValueError(f"mode profile source does not exist: {path}")
+ backup_path = _initial_backup_path(path)
+ if not backup_path.exists():
+ backup_path.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")
+
+ merged_operations = _merge_operations(profile.operations, canonical_updates)
+ document = _mode_profile_document(profile, merged_operations)
+ _replace_yaml(path, document)
+ self.reload()
+ return ModeProfile.from_yaml(path, self.profile_root)
+
+ def restore_initial_mode(self, profile: ModeProfile) -> ModeProfile:
+ path = Path(profile.source)
+ backup_path = _initial_backup_path(path)
+ if not backup_path.is_file():
+ raise ValueError(f"no initial mode backup found: {backup_path}")
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = path.with_suffix(path.suffix + ".tmp")
+ temporary.write_text(backup_path.read_text(encoding="utf-8"), encoding="utf-8")
+ temporary.replace(path)
+ self.reload()
+ return ModeProfile.from_yaml(path, self.profile_root)
+
+ def _profiles_for_vehicle(self, vehicle_id: str | None) -> list[ModeProfile]:
+ selected: dict[str, ModeProfile] = {}
+ order: list[str] = []
+ for profile in self._visible_candidates_for_vehicle(vehicle_id):
+ if profile.id not in selected:
+ order.append(profile.id)
+ selected[profile.id] = profile
+ continue
+ current = selected[profile.id]
+ if _profile_rank(profile, vehicle_id) >= _profile_rank(current, vehicle_id):
+ selected[profile.id] = profile
+ return [selected[profile_id] for profile_id in order]
+
+ def _visible_candidates_for_vehicle(
+ self,
+ vehicle_id: str | None,
+ ) -> list[ModeProfile]:
+ return [
+ profile
+ for profile in self._profiles
+ if _is_visible_for_vehicle(profile, vehicle_id)
+ ]
+
+
+def _canonical_operations(
+ operations: list[dict[str, Any] | ModeOperation],
+) -> list[ModeOperation]:
+ return [
+ operation
+ if isinstance(operation, ModeOperation)
+ else ModeOperation.from_dict(operation)
+ for operation in operations
+ ]
+
+
+def _operation_signature(operation: ModeOperation) -> tuple[str, str, str]:
+ return (operation.domain, operation.capability, operation.target_scope)
+
+
+def _merge_operations(
+ existing: tuple[ModeOperation, ...],
+ updates: list[ModeOperation],
+) -> list[ModeOperation]:
+ merged = list(existing)
+ positions = {
+ _operation_signature(operation): index
+ for index, operation in enumerate(merged)
+ }
+ for operation in updates:
+ signature = _operation_signature(operation)
+ index = positions.get(signature)
+ if index is None:
+ positions[signature] = len(merged)
+ merged.append(operation)
+ continue
+ merged[index] = operation
+ return merged
+
+
+def _initial_backup_path(path: Path) -> Path:
+ return path.with_suffix(path.suffix + ".initial.bak")
+
+
+def _mode_profile_document(
+ profile: ModeProfile,
+ operations: list[ModeOperation],
+) -> dict[str, Any]:
+ scope: dict[str, Any] = {
+ "common": profile.common,
+ "vehicle_ids": list(profile.vehicle_ids),
+ }
+ return {
+ "id": profile.id,
+ "name": profile.name,
+ "aliases": list(profile.aliases),
+ "description": profile.description,
+ "icon": profile.icon,
+ "scope": scope,
+ "operations": [operation.to_yaml() for operation in operations],
+ }
+
+
+def _replace_yaml(path: Path, document: dict[str, Any]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = path.with_suffix(path.suffix + ".tmp")
+ temporary.write_text(
+ yaml.safe_dump(document, allow_unicode=True, sort_keys=False),
+ encoding="utf-8",
+ )
+ temporary.replace(path)
+
+
+def _mode_scope(
+ document: dict[str, Any],
+ path: Path,
+ profile_root: Path | None,
+) -> tuple[bool, tuple[str, ...]]:
+ scope = document.get("scope")
+ if isinstance(scope, dict):
+ vehicle_ids = tuple(
+ str(vehicle_id).strip()
+ for vehicle_id in scope.get("vehicle_ids", [])
+ if str(vehicle_id).strip()
+ )
+ common = bool(scope.get("common", not vehicle_ids))
+ return common, vehicle_ids
+
+ if profile_root is not None:
+ try:
+ parts = path.relative_to(profile_root).parts
+ except ValueError:
+ parts = ()
+ if len(parts) >= 3 and parts[0] == "vehicles":
+ return False, (parts[1],)
+ if parts and parts[0] == "common":
+ return True, ()
+ return True, ()
+
+
+def _is_visible_for_vehicle(
+ profile: ModeProfile,
+ vehicle_id: str | None,
+) -> bool:
+ if vehicle_id is None:
+ return True
+ return profile.common or vehicle_id in profile.vehicle_ids
+
+
+def _profile_rank(profile: ModeProfile, vehicle_id: str | None) -> int:
+ if vehicle_id is not None and vehicle_id in profile.vehicle_ids:
+ return 2
+ if profile.common:
+ return 1
+ return 0
diff --git a/src/cognitive_agent/application/model_plan_adapter.py b/src/cognitive_agent/application/model_plan_adapter.py
index 4cb9942..a20a49e 100755
--- a/src/cognitive_agent/application/model_plan_adapter.py
+++ b/src/cognitive_agent/application/model_plan_adapter.py
@@ -10,6 +10,7 @@ from cognitive_agent.application.seat_calibration import (
ANGLE_CALIBRATIONS,
angle_to_position_pct,
)
+from cognitive_agent.application.vehicle_profile import VehicleProfile
SEAT_CAPABILITIES = {
@@ -23,6 +24,7 @@ SEAT_MOTION_CAPABILITIES = {
"seat.set_legrest_height": "legrest_height",
"seat.set_legrest_extension": "legrest_extension",
}
+SEAT_RELATIVE_PHYSICAL_PARAMETERS = {"distance_mm", "angle_delta_deg"}
SAVE_PRESET_CAPABILITY = "seat.save_posture_preset"
APPLY_PRESET_CAPABILITY = "seat.apply_posture_preset"
HEX_COLOR = re.compile(r"^#[0-9A-Fa-f]{6}$")
@@ -45,6 +47,7 @@ class ModelPlanAdapter:
self,
plan: dict[str, Any],
context: dict[str, Any] | None = None,
+ vehicle_profile: VehicleProfile | None = None,
) -> dict[str, Any]:
actions: list[dict[str, Any]] = []
domain_actions: list[dict[str, Any]] = []
@@ -73,6 +76,11 @@ class ModelPlanAdapter:
}
allowed_parameters = {item["name"] for item in capability.get("parameters", [])}
unknown_parameters = set(parameters) - allowed_parameters
+ if (
+ domain == "seat"
+ and capability_name in SEAT_MOTION_CAPABILITIES
+ ):
+ unknown_parameters -= SEAT_RELATIVE_PHYSICAL_PARAMETERS
if unknown_parameters:
ambiguities.append(
{
@@ -162,6 +170,7 @@ class ModelPlanAdapter:
operation_parameters,
ambiguities,
context,
+ vehicle_profile,
)
if action is not None:
actions.append(action)
@@ -174,7 +183,7 @@ class ModelPlanAdapter:
)
continue
action = self._seat_action(
- index, request, parameters, ambiguities, context
+ index, request, parameters, ambiguities, context, vehicle_profile
)
if action is not None:
actions.append(action)
@@ -367,6 +376,7 @@ class ModelPlanAdapter:
parameters: dict[str, Any],
ambiguities: list[dict[str, str]],
context: dict[str, Any],
+ vehicle_profile: VehicleProfile | None = None,
) -> dict[str, Any] | None:
target = request.get("target_scope")
if target not in {"driver", "passenger"}:
@@ -381,21 +391,68 @@ class ModelPlanAdapter:
capability = request["capability"]
if capability in SEAT_MOTION_CAPABILITIES:
axis = SEAT_MOTION_CAPABILITIES[capability]
+ physical_action = ModelPlanAdapter._relative_physical_motion_action(
+ index,
+ request,
+ parameters,
+ ambiguities,
+ context,
+ target,
+ axis,
+ vehicle_profile,
+ )
+ if physical_action is not None:
+ return physical_action
angle = parameters.get("angle_deg")
- if angle is not None and capability in ANGLE_CALIBRATIONS:
- calibration = ANGLE_CALIBRATIONS[capability]
- if (
- not isinstance(angle, (int, float))
- or isinstance(angle, bool)
- or not calibration.minimum_deg <= float(angle) <= calibration.maximum_deg
- ):
+ if angle is not None:
+ if not isinstance(angle, (int, float)) or isinstance(angle, bool):
ambiguities.append(
{
"field": f"requests[{index}].parameters.angle_deg",
- "question": (
- f"{capability} 的夹角必须在 "
- f"{calibration.minimum_deg:g}-{calibration.maximum_deg:g} 度之间。"
- ),
+ "question": f"{capability} 的夹角必须是数字。",
+ }
+ )
+ return None
+ profile_motor = (
+ vehicle_profile.seat_motors.get(axis)
+ if vehicle_profile is not None
+ else None
+ )
+ if profile_motor is not None and profile_motor.display_angle:
+ if not profile_motor.contains_display_angle(float(angle)):
+ display_angle = profile_motor.display_angle
+ ambiguities.append(
+ {
+ "field": f"requests[{index}].parameters.angle_deg",
+ "question": (
+ f"{capability} 的夹角必须在 "
+ f"{display_angle['zero_deg']:g}-{display_angle['max_deg']:g} 度之间。"
+ ),
+ }
+ )
+ return None
+ position_pct = profile_motor.position_pct_for_display_angle(
+ float(angle)
+ )
+ elif capability in ANGLE_CALIBRATIONS:
+ calibration = ANGLE_CALIBRATIONS[capability]
+ if not calibration.minimum_deg <= float(angle) <= calibration.maximum_deg:
+ ambiguities.append(
+ {
+ "field": f"requests[{index}].parameters.angle_deg",
+ "question": (
+ f"{capability} 的夹角必须在 "
+ f"{calibration.minimum_deg:g}-{calibration.maximum_deg:g} 度之间。"
+ ),
+ }
+ )
+ return None
+ position_pct = angle_to_position_pct(capability, float(angle))
+ else:
+ ambiguities.append(
+ {
+ "field": f"requests[{index}].parameters.position_pct",
+ "question": "当前车型未给出该座椅轴的角度标定,需要 0-100 的绝对百分比。",
}
)
return None
@@ -406,7 +463,7 @@ class ModelPlanAdapter:
"action_type": "position",
"target": target,
"axis": axis,
- "position_pct": angle_to_position_pct(capability, float(angle)),
+ "position_pct": position_pct,
"angle_deg": exact_angle,
}
return ModelPlanAdapter._preserve_relative_motion(
@@ -460,6 +517,157 @@ class ModelPlanAdapter:
),
}
+ @staticmethod
+ def _relative_physical_motion_action(
+ index: int,
+ request: dict[str, Any],
+ parameters: dict[str, Any],
+ ambiguities: list[dict[str, str]],
+ context: dict[str, Any],
+ target: str,
+ axis: str,
+ vehicle_profile: VehicleProfile | None,
+ ) -> dict[str, Any] | None:
+ physical_parameters = [
+ name
+ for name in ("distance_mm", "angle_delta_deg")
+ if parameters.get(name) is not None
+ ]
+ if not physical_parameters:
+ return None
+ if len(physical_parameters) > 1:
+ ambiguities.append(
+ {
+ "field": f"requests[{index}].parameters",
+ "question": "单个座椅轴相对移动只能提供一种物理增量。",
+ }
+ )
+ return None
+ if vehicle_profile is None:
+ ambiguities.append(
+ {
+ "field": f"requests[{index}].parameters.position_pct",
+ "question": "缺少车型座椅量程,无法把相对物理移动换算为绝对百分比。",
+ }
+ )
+ return None
+
+ parameter_name = physical_parameters[0]
+ delta = parameters[parameter_name]
+ if (
+ not isinstance(delta, (int, float))
+ or isinstance(delta, bool)
+ or float(delta) <= 0
+ ):
+ ambiguities.append(
+ {
+ "field": f"requests[{index}].parameters.{parameter_name}",
+ "question": "座椅相对物理移动量必须是正数。",
+ }
+ )
+ return None
+
+ motor = vehicle_profile.seat_motors.get(axis)
+ if motor is None:
+ ambiguities.append(
+ {
+ "field": f"requests[{index}].capability",
+ "question": f"车型 {vehicle_profile.name} 未配置 {axis} 轴量程。",
+ }
+ )
+ return None
+ expected_unit = "mm" if parameter_name == "distance_mm" else "degree"
+ if motor.unit != expected_unit:
+ ambiguities.append(
+ {
+ "field": f"requests[{index}].parameters.{parameter_name}",
+ "question": (
+ f"{axis} 轴使用 {motor.unit} 标定,不能用 {expected_unit} 增量换算。"
+ ),
+ }
+ )
+ return None
+
+ direction = str(request.get("operation", "set"))
+ if direction not in {"increase", "decrease"}:
+ ambiguities.append(
+ {
+ "field": f"requests[{index}].operation",
+ "question": "相对物理移动需要 increase 或 decrease 方向。",
+ }
+ )
+ return None
+ previous = (
+ context.get("devices", {})
+ .get(target, {})
+ .get("position", {})
+ .get(axis)
+ )
+ if not isinstance(previous, dict):
+ ambiguities.append(
+ {
+ "field": f"requests[{index}].target_scope",
+ "question": "缺少当前座椅姿态,无法安全执行相对物理移动。",
+ }
+ )
+ return None
+ current_pct = previous.get("position_pct")
+ if (
+ not isinstance(current_pct, (int, float))
+ or isinstance(current_pct, bool)
+ ):
+ ambiguities.append(
+ {
+ "field": f"requests[{index}].target_scope",
+ "question": "当前座椅百分比位置无效,无法安全执行相对物理移动。",
+ }
+ )
+ return None
+
+ position_pct = vehicle_profile.relative_target_pct(
+ axis,
+ current_pct=current_pct,
+ delta=float(delta),
+ direction=direction,
+ )
+ action: dict[str, Any] = {
+ "action_type": "position",
+ "target": target,
+ "axis": axis,
+ "position_pct": position_pct,
+ }
+ if parameter_name == "angle_delta_deg":
+ action["angle_deg"] = ModelPlanAdapter._relative_target_angle(
+ previous,
+ motor,
+ position_pct,
+ float(delta),
+ direction,
+ )
+ return ModelPlanAdapter._preserve_relative_motion(
+ index, request, action, context, ambiguities
+ )
+
+ @staticmethod
+ def _relative_target_angle(
+ previous: dict[str, Any],
+ motor: Any,
+ position_pct: int,
+ delta_deg: float,
+ direction: str,
+ ) -> int | float:
+ start_angle = previous.get("angle_deg")
+ if (
+ isinstance(start_angle, (int, float))
+ and not isinstance(start_angle, bool)
+ ):
+ signed_delta = delta_deg if direction == "increase" else -delta_deg
+ return _clean_number(float(start_angle) + signed_delta)
+ display_angle = motor.display_angle_for_pct(position_pct)
+ if display_angle is None:
+ return _clean_number(delta_deg)
+ return _clean_number(display_angle)
+
@staticmethod
def _preserve_relative_motion(
index: int,
@@ -530,3 +738,7 @@ class ModelPlanAdapter:
)
action["motion_intent"] = motion_intent
return action
+
+
+def _clean_number(value: float) -> int | float:
+ return int(value) if float(value).is_integer() else round(float(value), 1)
diff --git a/src/cognitive_agent/application/seat_service.py b/src/cognitive_agent/application/seat_service.py
index 92d4455..68e57ff 100755
--- a/src/cognitive_agent/application/seat_service.py
+++ b/src/cognitive_agent/application/seat_service.py
@@ -36,7 +36,22 @@ from seat_domain import ( # noqa: E402
from cognitive_agent.application.capability_registry import CapabilityRegistry # noqa: E402
from cognitive_agent.application.conversation_state import ConversationStateStore # noqa: E402
from cognitive_agent.application.domain_runtime import ConfiguredDomainRuntime # noqa: E402
-from cognitive_agent.application.model_plan_adapter import ModelPlanAdapter # noqa: E402
+from cognitive_agent.application.mode_profile import ( # noqa: E402
+ ModeProfile,
+ ModeProfileRegistry,
+)
+from cognitive_agent.application.model_plan_adapter import ( # noqa: E402
+ ModelPlanAdapter,
+ APPLY_PRESET_CAPABILITY,
+ SAVE_PRESET_CAPABILITY,
+ SEAT_CAPABILITIES,
+ SEAT_MOTION_CAPABILITIES,
+)
+from cognitive_agent.application.seat_calibration import position_pct_to_angle # noqa: E402
+from cognitive_agent.application.vehicle_profile import ( # noqa: E402
+ VehicleProfile,
+ VehicleProfileRegistry,
+)
from cognitive_agent.infrastructure.llm.openai_responses import ( # noqa: E402
OpenAIIntentError,
OpenAIIntentInterpreter,
@@ -52,7 +67,44 @@ def _now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
+def _clean_vehicle_number(value: float | None) -> int | float | None:
+ if value is None:
+ return None
+ return int(value) if float(value).is_integer() else round(float(value), 1)
+
+
CONVERSATION_ID_PATTERN = re.compile(r"^conv-[a-z0-9-]{6,80}$")
+SAVE_MODE_PROFILE_NAME = re.compile(
+ r"(?:固化为|保存为长期|保存成长期|长期保存为|记为长期|命名为)"
+ r"(?P[^,。,.!!\s]{1,64}?模式)"
+)
+UPDATE_MODE_PROFILE_PREFIX = re.compile(
+ r"^(?:请(?:帮我)?|帮我)?(?:修改一下|修改|调整|更改|更新|改一下)"
+ r"(?P[^,。,.!!\s]{1,64}?模式)"
+ r"(?:[,。,.!!\s]*(?P.*))?$"
+)
+UPDATE_MODE_PROFILE_BA = re.compile(
+ r"^(?:请(?:帮我)?|帮我)?(?:把|将)"
+ r"(?P[^,。,.!!\s]{1,64}?模式)"
+ r"(?:的)?(?P.+)$"
+)
+RESTORE_MODE_PROFILE_PREFIX = re.compile(
+ r"^(?:请(?:帮我)?|帮我)?(?:恢复|还原|重置|初始化)"
+ r"(?P[^,。,.!!\s]{1,64}?模式)"
+ r"(?:的)?(?:初始配置|初始参数|默认配置|原始配置|出厂配置)?$"
+)
+RESTORE_MODE_PROFILE_SUFFIX = re.compile(
+ r"^(?:请(?:帮我)?|帮我)?(?:把|将)?"
+ r"(?P[^,。,.!!\s]{1,64}?模式)"
+ r"(?:恢复|还原|重置|初始化)(?:到|为)?"
+ r"(?:初始配置|初始参数|默认配置|原始配置|出厂配置)?$"
+)
+SEAT_AXIS_TO_CAPABILITY = {
+ axis: capability for capability, axis in SEAT_MOTION_CAPABILITIES.items()
+}
+SEAT_FUNCTION_TO_CAPABILITY = {
+ function: capability for capability, function in SEAT_CAPABILITIES.items()
+}
class SeatDemoService:
@@ -89,6 +141,8 @@ class SeatDemoService:
)
self.capability_context = self.capability_registry.snapshot()
self.model_plan_adapter = ModelPlanAdapter(self.capability_context)
+ self.vehicle_profiles = VehicleProfileRegistry(repository_root / "config" / "vehicles")
+ self.mode_profiles = ModeProfileRegistry(repository_root / "config" / "modes")
self._artifact_root = repository_root / "artifacts" / "runs"
self.conversations = ConversationStateStore(
preset_root=repository_root / "artifacts" / "user-presets"
@@ -111,6 +165,7 @@ class SeatDemoService:
domain_runtime_info = [
runtime.runtime_info() for runtime in self.domain_runtimes.values()
]
+ default_vehicle = self.vehicle_profiles.default()
return {
"service": "cabin-cognitive-orchestrator",
"domain": "cabin",
@@ -122,6 +177,10 @@ class SeatDemoService:
"openai_available": self.openai.available,
"openai_model": self.openai.model,
"conversation_memory": True,
+ "default_vehicle_id": default_vehicle.id,
+ "vehicle": default_vehicle.public_dict(),
+ "vehicles": self.vehicle_profiles.public_profiles(),
+ "modes": self.mode_profiles.public_modes(default_vehicle.id),
"preset_storage": "conversation_durable",
"inferred_level_range": [1, 3],
"motion_position_range_pct": [0, 100],
@@ -166,6 +225,7 @@ class SeatDemoService:
conversation_id: str | None = None,
execution_mode: str = "dry_run",
approval_token: str | None = None,
+ vehicle_id: str | None = None,
) -> dict[str, Any]:
utterance = utterance.strip()
if not utterance:
@@ -176,15 +236,32 @@ class SeatDemoService:
raise ValueError("provider must be auto, openai, or local")
if execution_mode not in {"dry_run", "hardware"}:
raise ValueError("execution_mode must be dry_run or hardware")
+ mode_save_name = self._parse_mode_profile_save_name(utterance)
+ mode_restore_name = self._parse_mode_profile_restore_name(utterance)
+ mode_update = self._parse_mode_profile_update(utterance)
+ control_plane_request = (
+ mode_save_name is not None
+ or mode_restore_name is not None
+ or mode_update is not None
+ )
hardware_request = execution_mode == "hardware"
- if hardware_request:
+ if hardware_request and not control_plane_request:
self.hardware_settings.authorize(approval_token)
conversation_id = conversation_id or f"conv-{uuid.uuid4().hex}"
if not CONVERSATION_ID_PATTERN.fullmatch(conversation_id):
raise ValueError("conversation_id must start with conv- and contain only lowercase letters, numbers, or hyphens")
+ vehicle_profile = self.vehicle_profiles.get(vehicle_id)
request_id = f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6]}"
- context = self.conversations.snapshot(conversation_id)
+ context = self._context_for_vehicle(
+ self.conversations.snapshot(conversation_id),
+ vehicle_profile,
+ )
+ context["mode_profiles"] = self.mode_profiles.public_modes(vehicle_profile.id)
+ matched_modes = self.mode_profiles.match_text(
+ utterance,
+ vehicle_id=vehicle_profile.id,
+ )
traces: list[dict[str, Any]] = []
result: dict[str, Any] = {
"request_id": request_id,
@@ -193,7 +270,14 @@ class SeatDemoService:
"status": "processing",
"provider_requested": provider,
"execution_mode": execution_mode,
- "hardware_execution_allowed": hardware_request,
+ "hardware_execution_allowed": (
+ hardware_request and not control_plane_request
+ ),
+ "vehicle": vehicle_profile.public_dict(),
+ "matched_modes": [
+ profile.public_dict() for profile in matched_modes
+ ],
+ "mode_profile": None,
"decision_summary": [],
"deferred_requests": [],
"model_requests": [],
@@ -216,7 +300,48 @@ class SeatDemoService:
}
started = time.perf_counter()
- parsed, provider_used, provider_notice = self._interpret(utterance, provider, context)
+ if mode_save_name is not None:
+ return self._process_mode_profile_save(
+ utterance,
+ request_id,
+ conversation_id,
+ context,
+ mode_save_name,
+ vehicle_profile,
+ result,
+ traces,
+ )
+ if mode_restore_name is not None:
+ return self._process_mode_profile_restore(
+ utterance,
+ request_id,
+ conversation_id,
+ mode_restore_name,
+ vehicle_profile,
+ result,
+ traces,
+ )
+ if mode_update is not None:
+ mode_name, change_text = mode_update
+ return self._process_mode_profile_update(
+ utterance,
+ request_id,
+ provider,
+ conversation_id,
+ context,
+ mode_name,
+ change_text,
+ vehicle_profile,
+ result,
+ traces,
+ )
+ parsed, provider_used, provider_notice = self._interpret(
+ utterance,
+ provider,
+ context,
+ vehicle_profile,
+ )
+ parsed = self._apply_mode_profiles(parsed, matched_modes, context, vehicle_profile)
self._trace(
traces,
request_id,
@@ -245,7 +370,8 @@ class SeatDemoService:
"succeeded",
(
f"已将会话 {conversation_id} 的第 {context['turn_count']} 轮执行状态"
- "和域能力注册表作为模型上下文;本地层不改写 GPT 动作。"
+ "、域能力注册表和长期模式配置作为模型上下文;"
+ "本地层仅注入已命中的长期模式动作。"
),
context_started,
{
@@ -256,6 +382,7 @@ class SeatDemoService:
"capability_domains": [
item["domain"] for item in self.capability_context["domains"]
],
+ "matched_modes": [profile.id for profile in matched_modes],
},
)
@@ -468,6 +595,10 @@ class SeatDemoService:
preset_config,
parsed["decision_summary"],
)
+ updated_context = self._context_for_vehicle(
+ updated_context,
+ vehicle_profile,
+ )
result["status"] = "succeeded"
result["conversation_turn"] = updated_context["turn_count"]
result["actuator_state"] = updated_context["devices"]
@@ -690,6 +821,10 @@ class SeatDemoService:
state_actions,
parsed["decision_summary"],
)
+ updated_context = self._context_for_vehicle(
+ updated_context,
+ vehicle_profile,
+ )
result["conversation_turn"] = updated_context["turn_count"]
result["actuator_state"] = updated_context["devices"]
result["domain_state"] = updated_context["domain_devices"]
@@ -838,6 +973,862 @@ class SeatDemoService:
existing.add(signature)
return synchronized
+ @staticmethod
+ def _parse_mode_profile_save_name(utterance: str) -> str | None:
+ explicit = SAVE_MODE_PROFILE_NAME.search(utterance)
+ if explicit is not None:
+ return explicit.group("name").strip()
+
+ save_like = any(word in utterance for word in ("保存", "记住", "记为"))
+ mode_context = any(
+ word in utterance
+ for word in ("这个模式", "这个配置", "当前模式", "长期", "固化")
+ )
+ if not save_like or not mode_context:
+ return None
+ generic = re.search(
+ r"(?:保存为|保存成|记为|命名为)(?P[^,。,.!!\s]{1,64}?模式)",
+ utterance,
+ )
+ return generic.group("name").strip() if generic is not None else None
+
+ @staticmethod
+ def _parse_mode_profile_update(utterance: str) -> tuple[str, str] | None:
+ explicit = UPDATE_MODE_PROFILE_PREFIX.search(utterance)
+ if explicit is not None:
+ change_text = str(explicit.group("change") or "").strip()
+ return explicit.group("name").strip(), change_text
+
+ inline = UPDATE_MODE_PROFILE_BA.search(utterance)
+ if inline is None:
+ return None
+ change_text = inline.group("change").strip("的").strip()
+ if not any(word in change_text for word in ("改", "调", "设", "更新", "变")):
+ return None
+ return inline.group("name").strip(), change_text
+
+ @staticmethod
+ def _parse_mode_profile_restore_name(utterance: str) -> str | None:
+ explicit = RESTORE_MODE_PROFILE_PREFIX.search(utterance)
+ if explicit is not None:
+ return explicit.group("name").strip()
+ suffix = RESTORE_MODE_PROFILE_SUFFIX.search(utterance)
+ return suffix.group("name").strip() if suffix is not None else None
+
+ def _apply_mode_profiles(
+ self,
+ parsed: dict[str, Any],
+ matched_modes: list[ModeProfile],
+ context: dict[str, Any],
+ vehicle_profile: VehicleProfile,
+ ) -> dict[str, Any]:
+ if not matched_modes:
+ return parsed
+ if self._contains_seat_preset_request(parsed):
+ return parsed
+
+ mode_requests = self.mode_profiles.requests_for_profiles(matched_modes)
+ detected_domains = []
+ for request in mode_requests:
+ if request["domain"] not in detected_domains:
+ detected_domains.append(request["domain"])
+ mode_plan = {
+ "detected_domains": detected_domains or ["seat"],
+ "requests": mode_requests,
+ "ambiguities": [],
+ "decision_summary": [
+ "命中长期模式配置:"
+ + "、".join(profile.name for profile in matched_modes)
+ + "。"
+ ],
+ "confidence": max(float(parsed.get("confidence", 0)), 0.97),
+ }
+ mode_parsed = self.model_plan_adapter.adapt(
+ mode_plan,
+ context,
+ vehicle_profile,
+ )
+ merged = deepcopy(parsed)
+ merged["actions"] = self._merge_control_actions(
+ mode_parsed.get("actions", []),
+ parsed.get("actions", []),
+ )
+ merged["domain_actions"] = self._merge_control_actions(
+ mode_parsed.get("domain_actions", []),
+ parsed.get("domain_actions", []),
+ )
+ for key in (
+ "preset_actions",
+ "preset_events",
+ "deferred_requests",
+ "ambiguities",
+ "model_requests",
+ ):
+ merged[key] = self._merge_unique(
+ mode_parsed.get(key, []),
+ parsed.get(key, []),
+ )
+ merged["decision_summary"] = [
+ *mode_parsed.get("decision_summary", []),
+ *parsed.get("decision_summary", []),
+ ]
+ merged["detected_domains"] = self._merge_domains(
+ mode_parsed.get("detected_domains", []),
+ parsed.get("detected_domains", []),
+ )
+ merged["confidence"] = max(
+ float(mode_parsed.get("confidence", 0)),
+ float(parsed.get("confidence", 0)),
+ )
+ return merged
+
+ @staticmethod
+ def _contains_seat_preset_request(parsed: dict[str, Any]) -> bool:
+ capabilities = {
+ request.get("capability")
+ for request in parsed.get("model_requests", [])
+ if isinstance(request, dict)
+ }
+ return bool(
+ parsed.get("preset_actions")
+ or parsed.get("preset_events")
+ or SAVE_PRESET_CAPABILITY in capabilities
+ or APPLY_PRESET_CAPABILITY in capabilities
+ )
+
+ @classmethod
+ def _merge_control_actions(
+ cls,
+ mode_items: list[dict[str, Any]],
+ parsed_items: list[dict[str, Any]],
+ ) -> list[dict[str, Any]]:
+ parsed_controls = {
+ cls._control_signature(item)
+ for item in parsed_items
+ }
+ candidates = [
+ *parsed_items,
+ *(
+ item
+ for item in mode_items
+ if cls._control_signature(item) not in parsed_controls
+ ),
+ ]
+ return cls._merge_unique([], candidates)
+
+ @staticmethod
+ def _control_signature(action: dict[str, Any]) -> tuple[Any, ...]:
+ if action.get("domain"):
+ return (
+ action.get("domain"),
+ action.get("capability"),
+ action.get("target"),
+ )
+ if action.get("action_type") == "position":
+ return (
+ "seat",
+ "position",
+ action.get("target"),
+ action.get("axis"),
+ )
+ return (
+ "seat",
+ "function",
+ action.get("target"),
+ action.get("function"),
+ )
+
+ @staticmethod
+ def _merge_unique(
+ first: list[dict[str, Any]],
+ second: list[dict[str, Any]],
+ ) -> list[dict[str, Any]]:
+ merged = []
+ seen: set[str] = set()
+ for item in [*first, *second]:
+ signature = json.dumps(item, ensure_ascii=False, sort_keys=True)
+ if signature in seen:
+ continue
+ seen.add(signature)
+ merged.append(item)
+ return merged
+
+ @staticmethod
+ def _merge_domains(
+ first: list[str],
+ second: list[str],
+ ) -> list[str]:
+ domains = []
+ for domain in [*first, *second]:
+ if domain not in domains:
+ domains.append(domain)
+ return domains or ["seat"]
+
+ def _process_mode_profile_update(
+ self,
+ utterance: str,
+ request_id: str,
+ provider: str,
+ conversation_id: str,
+ context: dict[str, Any],
+ mode_name: str,
+ change_text: str,
+ vehicle_profile: VehicleProfile,
+ result: dict[str, Any],
+ traces: list[dict[str, Any]],
+ ) -> dict[str, Any]:
+ started = time.perf_counter()
+ result["provider_used"] = "mode_profile_registry"
+ result["decision_summary"] = [
+ f"识别为长期模式修改请求,目标模式为“{mode_name}”。"
+ ]
+ self._trace(
+ traces,
+ request_id,
+ "intent_parse",
+ "succeeded",
+ "识别到长期模式配置修改请求;本次不调用 CAN 编译路径。",
+ started,
+ {"mode_name": mode_name, "change_text": change_text},
+ domain="mode",
+ )
+
+ profile = self._resolve_single_mode_profile(
+ mode_name,
+ vehicle_profile,
+ result,
+ traces,
+ request_id,
+ )
+ if profile is None:
+ self._persist(result)
+ return result
+ if not change_text:
+ result["status"] = "needs_confirmation"
+ result["validation"] = [
+ {
+ "severity": "error",
+ "code": "MODE_PROFILE_UPDATE_MISSING_CHANGE",
+ "path": "utterance",
+ "message": "请说明要把长期模式中的哪些参数改成什么目标值。",
+ }
+ ]
+ self._persist(result)
+ return result
+
+ parsed, provider_used, provider_notice = self._interpret(
+ change_text,
+ provider,
+ context,
+ vehicle_profile,
+ )
+ result["provider_used"] = f"mode_profile_registry+{provider_used}"
+ result["decision_summary"].extend(parsed.get("decision_summary", []))
+ if provider_notice:
+ result["decision_summary"].append(provider_notice)
+ result["deferred_requests"] = parsed.get("deferred_requests", [])
+ result["model_requests"] = parsed.get("model_requests", [])
+
+ source_actions = [
+ *parsed.get("actions", []),
+ *parsed.get("domain_actions", []),
+ ]
+ mode_operations = self._mode_operations_from_actions(source_actions)
+ if not mode_operations:
+ validation = [
+ {
+ "severity": "error",
+ "code": "NO_MODE_PROFILE_UPDATE_OPERATIONS",
+ "path": "spec.operations",
+ "message": "没有从修改说明中得到可写入长期模式库的可执行配置。",
+ }
+ ]
+ validation.extend(
+ {
+ "severity": "error",
+ "code": "AMBIGUOUS_INTENT",
+ "path": ambiguity.get("field", "utterance"),
+ "message": ambiguity.get("question", "修改说明不完整。"),
+ }
+ for ambiguity in parsed.get("ambiguities", [])
+ )
+ result["validation"] = validation
+ result["status"] = (
+ "capability_unavailable"
+ if parsed.get("deferred_requests") and not parsed.get("ambiguities")
+ else "needs_confirmation"
+ )
+ self._persist(result)
+ return result
+
+ updated_profile = self.mode_profiles.update_mode(profile, mode_operations)
+ profile_public = updated_profile.public_dict()
+ source_path = self._display_path(Path(updated_profile.source))
+ backup_path = self._display_path(
+ Path(updated_profile.source).with_suffix(".yaml.initial.bak")
+ )
+ result["mode_profile"] = {
+ **profile_public,
+ "source": source_path,
+ "initial_backup_source": backup_path,
+ }
+ result["matched_modes"] = [profile_public]
+ result["model_requests"] = self.mode_profiles.requests_for_profiles(
+ [updated_profile]
+ )
+ result["decision_summary"].append(
+ f"已更新长期模式“{updated_profile.name}”,原始配置备份保留为初始化来源。"
+ )
+ self._trace(
+ traces,
+ request_id,
+ "function_generate",
+ "succeeded",
+ "已将修改后的可执行配置写回长期模式 YAML。",
+ time.perf_counter(),
+ {
+ "mode_profile_id": updated_profile.id,
+ "operation_count": len(updated_profile.operations),
+ "updated_operation_count": len(mode_operations),
+ "source": source_path,
+ "initial_backup_source": backup_path,
+ },
+ domain="mode",
+ )
+
+ execution_report = self._mode_profile_control_report(
+ request_id,
+ "update_mode_profile",
+ updated_profile,
+ f"已更新长期模式“{updated_profile.name}”。",
+ {
+ "updated_operation_count": len(mode_operations),
+ "source": source_path,
+ "initial_backup_source": backup_path,
+ },
+ )
+ validation = self.contracts.validate(execution_report)
+ result["validation"] = validation
+ result["execution_report"] = execution_report
+ if validation:
+ result["status"] = "execution_report_invalid"
+ self._persist(result)
+ return result
+
+ updated_context = self.conversations.record_mode_profile_event(
+ conversation_id,
+ utterance,
+ result["mode_profile"],
+ source_actions,
+ result["decision_summary"],
+ "mode_profile_update",
+ )
+ result["status"] = (
+ "partially_succeeded"
+ if parsed.get("deferred_requests")
+ else "succeeded"
+ )
+ result["conversation_turn"] = updated_context["turn_count"]
+ result["actuator_state"] = updated_context["devices"]
+ result["domain_state"] = updated_context["domain_devices"]
+ self._trace(
+ traces,
+ request_id,
+ "execute",
+ "succeeded",
+ "长期模式修改属于控制平面操作,未生成或发送 CAN 帧。",
+ time.perf_counter(),
+ {
+ "mode": "control_plane",
+ "mode_profile_id": updated_profile.id,
+ "can_command_emitted": False,
+ },
+ domain="mode",
+ )
+ self._trace(
+ traces,
+ request_id,
+ "state_update",
+ "succeeded",
+ f"长期模式“{updated_profile.name}”已更新,可被后续自然语言请求命中。",
+ time.perf_counter(),
+ {
+ "turn_count": updated_context["turn_count"],
+ "mode_profile_id": updated_profile.id,
+ },
+ domain="mode",
+ )
+ self._persist(result)
+ return result
+
+ def _process_mode_profile_restore(
+ self,
+ utterance: str,
+ request_id: str,
+ conversation_id: str,
+ mode_name: str,
+ vehicle_profile: VehicleProfile,
+ result: dict[str, Any],
+ traces: list[dict[str, Any]],
+ ) -> dict[str, Any]:
+ started = time.perf_counter()
+ result["provider_used"] = "mode_profile_registry"
+ result["decision_summary"] = [
+ f"识别为长期模式初始化请求,目标模式为“{mode_name}”。"
+ ]
+ self._trace(
+ traces,
+ request_id,
+ "intent_parse",
+ "succeeded",
+ "识别到长期模式初始化请求;本次不调用模型或 CAN 编译路径。",
+ started,
+ {"mode_name": mode_name},
+ domain="mode",
+ )
+
+ profile = self._resolve_single_mode_profile(
+ mode_name,
+ vehicle_profile,
+ result,
+ traces,
+ request_id,
+ )
+ if profile is None:
+ self._persist(result)
+ return result
+
+ try:
+ restored_profile = self.mode_profiles.restore_initial_mode(profile)
+ except ValueError as exc:
+ result["status"] = "needs_confirmation"
+ result["validation"] = [
+ {
+ "severity": "error",
+ "code": "MODE_PROFILE_INITIAL_BACKUP_MISSING",
+ "path": "mode_profile.initial_backup",
+ "message": str(exc),
+ }
+ ]
+ self._persist(result)
+ return result
+
+ profile_public = restored_profile.public_dict()
+ source_path = self._display_path(Path(restored_profile.source))
+ backup_path = self._display_path(
+ Path(restored_profile.source).with_suffix(".yaml.initial.bak")
+ )
+ result["mode_profile"] = {
+ **profile_public,
+ "source": source_path,
+ "initial_backup_source": backup_path,
+ }
+ result["matched_modes"] = [profile_public]
+ result["model_requests"] = self.mode_profiles.requests_for_profiles(
+ [restored_profile]
+ )
+ result["decision_summary"].append(
+ f"已将长期模式“{restored_profile.name}”恢复为初始配置。"
+ )
+ self._trace(
+ traces,
+ request_id,
+ "function_generate",
+ "succeeded",
+ "已从初始备份恢复长期模式 YAML。",
+ time.perf_counter(),
+ {
+ "mode_profile_id": restored_profile.id,
+ "operation_count": len(restored_profile.operations),
+ "source": source_path,
+ "initial_backup_source": backup_path,
+ },
+ domain="mode",
+ )
+
+ execution_report = self._mode_profile_control_report(
+ request_id,
+ "restore_mode_profile",
+ restored_profile,
+ f"已恢复长期模式“{restored_profile.name}”。",
+ {
+ "source": source_path,
+ "initial_backup_source": backup_path,
+ },
+ )
+ validation = self.contracts.validate(execution_report)
+ result["validation"] = validation
+ result["execution_report"] = execution_report
+ if validation:
+ result["status"] = "execution_report_invalid"
+ self._persist(result)
+ return result
+
+ updated_context = self.conversations.record_mode_profile_event(
+ conversation_id,
+ utterance,
+ result["mode_profile"],
+ [],
+ result["decision_summary"],
+ "mode_profile_restore",
+ )
+ result["status"] = "succeeded"
+ result["conversation_turn"] = updated_context["turn_count"]
+ result["actuator_state"] = updated_context["devices"]
+ result["domain_state"] = updated_context["domain_devices"]
+ self._trace(
+ traces,
+ request_id,
+ "execute",
+ "succeeded",
+ "长期模式初始化属于控制平面操作,未生成或发送 CAN 帧。",
+ time.perf_counter(),
+ {
+ "mode": "control_plane",
+ "mode_profile_id": restored_profile.id,
+ "can_command_emitted": False,
+ },
+ domain="mode",
+ )
+ self._trace(
+ traces,
+ request_id,
+ "state_update",
+ "succeeded",
+ f"长期模式“{restored_profile.name}”已恢复,可被后续自然语言请求命中。",
+ time.perf_counter(),
+ {
+ "turn_count": updated_context["turn_count"],
+ "mode_profile_id": restored_profile.id,
+ },
+ domain="mode",
+ )
+ self._persist(result)
+ return result
+
+ def _resolve_single_mode_profile(
+ self,
+ mode_name: str,
+ vehicle_profile: VehicleProfile,
+ result: dict[str, Any],
+ traces: list[dict[str, Any]],
+ request_id: str,
+ ) -> ModeProfile | None:
+ matches = self.mode_profiles.match_text(
+ mode_name,
+ vehicle_id=vehicle_profile.id,
+ )
+ result["matched_modes"] = [profile.public_dict() for profile in matches]
+ if len(matches) == 1:
+ return matches[0]
+ result["status"] = "needs_confirmation"
+ result["validation"] = [
+ {
+ "severity": "error",
+ "code": (
+ "MODE_PROFILE_NOT_FOUND"
+ if not matches
+ else "MODE_PROFILE_AMBIGUOUS"
+ ),
+ "path": "utterance.mode_profile",
+ "message": (
+ f"未在车型 {vehicle_profile.id} 的长期模式库中找到“{mode_name}”。"
+ if not matches
+ else f"“{mode_name}”命中了多个长期模式,请指定更完整名称。"
+ ),
+ }
+ ]
+ self._trace(
+ traces,
+ request_id,
+ "context_resolve",
+ "failed",
+ result["validation"][0]["message"],
+ time.perf_counter(),
+ {
+ "mode_name": mode_name,
+ "vehicle_id": vehicle_profile.id,
+ "match_count": len(matches),
+ },
+ domain="mode",
+ )
+ return None
+
+ def _mode_profile_control_report(
+ self,
+ request_id: str,
+ operation: str,
+ profile: ModeProfile,
+ message: str,
+ telemetry: dict[str, Any],
+ ) -> dict[str, Any]:
+ return {
+ "apiVersion": "cognitive-agent/v1alpha1",
+ "kind": "ExecutionReport",
+ "metadata": {
+ "id": f"report-{request_id}",
+ "name": "Mode profile control report",
+ "version": "1.0.0",
+ "request_id": request_id,
+ "labels": {"control_plane": "mode_profile_store"},
+ },
+ "spec": {
+ "mode": "dry_run",
+ "status": "succeeded",
+ "started_at": _now(),
+ "finished_at": _now(),
+ "results": [
+ {
+ "ref": f"{operation}-{profile.id}",
+ "domain": "mode",
+ "status": "succeeded",
+ "message": message,
+ "telemetry": {
+ "operation": operation,
+ "mode_profile_id": profile.id,
+ "operation_count": len(profile.operations),
+ "storage_scope": "repository_config",
+ "can_command_emitted": False,
+ **telemetry,
+ },
+ }
+ ],
+ },
+ }
+
+ def _display_path(self, path: Path) -> str:
+ try:
+ return str(path.relative_to(self.root))
+ except ValueError:
+ return str(path)
+
+ def _process_mode_profile_save(
+ self,
+ utterance: str,
+ request_id: str,
+ conversation_id: str,
+ context: dict[str, Any],
+ mode_name: str,
+ vehicle_profile: VehicleProfile,
+ result: dict[str, Any],
+ traces: list[dict[str, Any]],
+ ) -> dict[str, Any]:
+ started = time.perf_counter()
+ result["provider_used"] = "mode_profile_registry"
+ result["decision_summary"] = [
+ f"识别为长期模式固化请求,目标名称为“{mode_name}”。"
+ ]
+ self._trace(
+ traces,
+ request_id,
+ "intent_parse",
+ "succeeded",
+ "识别到长期模式配置保存请求;不调用 CAN 编译路径。",
+ started,
+ {"mode_name": mode_name},
+ domain="mode",
+ )
+
+ source_actions, mode_operations = self._last_mode_source(context)
+ if not mode_operations:
+ result["status"] = "needs_confirmation"
+ result["validation"] = [
+ {
+ "severity": "error",
+ "code": "NO_RECENT_MODE_SOURCE",
+ "path": "conversation.recent_turns",
+ "message": "当前会话没有可固化为长期模式的最近成功动作。",
+ }
+ ]
+ self._persist(result)
+ return result
+
+ profile = self.mode_profiles.save_mode(
+ mode_name,
+ mode_operations,
+ vehicle_id=vehicle_profile.id,
+ )
+ profile_public = profile.public_dict()
+ profile_path = Path(profile.source)
+ try:
+ source_path = str(profile_path.relative_to(self.root))
+ except ValueError:
+ source_path = str(profile_path)
+ result["mode_profile"] = {
+ **profile_public,
+ "source": source_path,
+ }
+ result["matched_modes"] = [profile_public]
+ result["decision_summary"].append(
+ f"已将最近一次成功动作固化为长期模式“{profile.name}”。"
+ )
+ result["model_requests"] = self.mode_profiles.requests_for_profiles([profile])
+ generation_started = time.perf_counter()
+ self._trace(
+ traces,
+ request_id,
+ "function_generate",
+ "succeeded",
+ "已写入长期模式 YAML;该配置将在后续请求中重新生成 FunctionConfig。",
+ generation_started,
+ {
+ "mode_profile_id": profile.id,
+ "operation_count": len(profile.operations),
+ "source": source_path,
+ },
+ domain="mode",
+ )
+ execution_report = {
+ "apiVersion": "cognitive-agent/v1alpha1",
+ "kind": "ExecutionReport",
+ "metadata": {
+ "id": f"report-{request_id}",
+ "name": "Mode profile save report",
+ "version": "1.0.0",
+ "request_id": request_id,
+ "labels": {"control_plane": "mode_profile_store"},
+ },
+ "spec": {
+ "mode": "dry_run",
+ "status": "succeeded",
+ "started_at": _now(),
+ "finished_at": _now(),
+ "results": [
+ {
+ "ref": f"save-{profile.id}",
+ "domain": "mode",
+ "status": "succeeded",
+ "message": f"已保存长期模式“{profile.name}”。",
+ "telemetry": {
+ "operation": "save_mode_profile",
+ "mode_profile_id": profile.id,
+ "operation_count": len(profile.operations),
+ "storage_scope": "repository_config",
+ "source": source_path,
+ "can_command_emitted": False,
+ },
+ }
+ ],
+ },
+ }
+ validation = self.contracts.validate(execution_report)
+ result["validation"] = validation
+ result["execution_report"] = execution_report
+ if validation:
+ result["status"] = "execution_report_invalid"
+ self._persist(result)
+ return result
+
+ updated_context = self.conversations.save_mode_profile(
+ conversation_id,
+ utterance,
+ result["mode_profile"],
+ source_actions,
+ result["decision_summary"],
+ )
+ result["status"] = "succeeded"
+ result["conversation_turn"] = updated_context["turn_count"]
+ result["actuator_state"] = updated_context["devices"]
+ result["domain_state"] = updated_context["domain_devices"]
+ self._trace(
+ traces,
+ request_id,
+ "execute",
+ "succeeded",
+ "长期模式保存属于控制平面操作,未生成或发送 CAN 帧。",
+ time.perf_counter(),
+ {
+ "mode": "control_plane",
+ "mode_profile_id": profile.id,
+ "can_command_emitted": False,
+ },
+ domain="mode",
+ )
+ self._trace(
+ traces,
+ request_id,
+ "state_update",
+ "succeeded",
+ f"长期模式“{profile.name}”已登记,可被后续自然语言请求命中。",
+ time.perf_counter(),
+ {
+ "turn_count": updated_context["turn_count"],
+ "mode_profile_id": profile.id,
+ },
+ domain="mode",
+ )
+ self._persist(result)
+ return result
+
+ def _last_mode_source(
+ self,
+ context: dict[str, Any],
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
+ for turn in reversed(context.get("recent_turns", [])):
+ actions = turn.get("actions", [])
+ if not isinstance(actions, list):
+ continue
+ operations = self._mode_operations_from_actions(actions)
+ if operations:
+ return deepcopy(actions), operations
+ return [], []
+
+ def _mode_operations_from_actions(
+ self,
+ actions: list[dict[str, Any]],
+ ) -> list[dict[str, Any]]:
+ operations = []
+ for action in actions:
+ operation = self._mode_operation_from_action(action)
+ if operation is not None:
+ operations.append(operation)
+ return operations
+
+ def _mode_operation_from_action(
+ self,
+ action: dict[str, Any],
+ ) -> dict[str, Any] | None:
+ domain = action.get("domain")
+ if domain in self.domain_runtimes:
+ return {
+ "domain": domain,
+ "capability": action["capability"],
+ "target_scope": action["target"],
+ "operation": action.get("operation", "set"),
+ "parameters": dict(action.get("parameters", {})),
+ "reason": action.get("reason", "用户固化的跨域模式动作。"),
+ }
+ if action.get("action_type") == "position":
+ capability = SEAT_AXIS_TO_CAPABILITY.get(str(action.get("axis")))
+ if capability is None:
+ return None
+ parameters = {"position_pct": action["position_pct"]}
+ if action.get("angle_deg") is not None:
+ parameters["angle_deg"] = action["angle_deg"]
+ return {
+ "domain": "seat",
+ "capability": capability,
+ "target_scope": action["target"],
+ "operation": "set",
+ "parameters": parameters,
+ "reason": "用户固化的座椅位置模式动作。",
+ }
+ function = action.get("function")
+ capability = SEAT_FUNCTION_TO_CAPABILITY.get(str(function))
+ if capability is None:
+ return None
+ parameters = {"state": action.get("state")}
+ if action.get("requested_level") is not None:
+ parameters["level"] = action["requested_level"]
+ return {
+ "domain": "seat",
+ "capability": capability,
+ "target_scope": action["target"],
+ "operation": "set",
+ "parameters": parameters,
+ "reason": "用户固化的座椅舒适功能模式动作。",
+ }
+
def _process_orchestrated(
self,
utterance: str,
@@ -1183,8 +2174,13 @@ class SeatDemoService:
}
def _interpret(
- self, utterance: str, provider: str, context: dict[str, Any]
+ self,
+ utterance: str,
+ provider: str,
+ context: dict[str, Any],
+ vehicle_profile: VehicleProfile | None = None,
) -> tuple[dict[str, Any], str, str | None]:
+ vehicle_profile = vehicle_profile or self.vehicle_profiles.default()
if provider == "local":
return parse_local_intent(utterance, context), "local_rules", None
if provider == "openai":
@@ -1192,7 +2188,7 @@ class SeatDemoService:
raise ValueError("GPT 模式需要设置 OPENAI_API_KEY")
plan = self.openai.interpret(utterance, context, self.capability_context)
return (
- self.model_plan_adapter.adapt(plan, context),
+ self.model_plan_adapter.adapt(plan, context, vehicle_profile),
f"openai:{self.openai.model}",
None,
)
@@ -1200,7 +2196,7 @@ class SeatDemoService:
try:
plan = self.openai.interpret(utterance, context, self.capability_context)
return (
- self.model_plan_adapter.adapt(plan, context),
+ self.model_plan_adapter.adapt(plan, context, vehicle_profile),
f"openai:{self.openai.model}",
None,
)
@@ -1216,6 +2212,41 @@ class SeatDemoService:
"未配置 OPENAI_API_KEY,本次使用可重复的本地规则解析。",
)
+ @staticmethod
+ def _context_for_vehicle(
+ context: dict[str, Any],
+ vehicle_profile: VehicleProfile,
+ ) -> dict[str, Any]:
+ projected = deepcopy(context)
+ projected["vehicle"] = vehicle_profile.public_dict()
+ for target_state in projected.get("devices", {}).values():
+ positions = target_state.get("position", {})
+ for axis, state in positions.items():
+ if not isinstance(state, dict):
+ continue
+ motor = vehicle_profile.seat_motors.get(str(axis))
+ if motor is None or motor.display_angle is None:
+ continue
+ position_pct = state.get("position_pct")
+ if (
+ not isinstance(position_pct, (int, float))
+ or isinstance(position_pct, bool)
+ ):
+ continue
+ current_angle = state.get("angle_deg")
+ legacy_angle = position_pct_to_angle(str(axis), float(position_pct))
+ should_project = current_angle is None or (
+ legacy_angle is not None
+ and isinstance(current_angle, (int, float))
+ and not isinstance(current_angle, bool)
+ and abs(float(current_angle) - float(legacy_angle)) < 0.0001
+ )
+ if should_project:
+ state["angle_deg"] = _clean_vehicle_number(
+ motor.display_angle_for_pct(int(position_pct))
+ )
+ return projected
+
@staticmethod
def _trace(
traces: list[dict[str, Any]],
diff --git a/src/cognitive_agent/application/vehicle_profile.py b/src/cognitive_agent/application/vehicle_profile.py
new file mode 100644
index 0000000..4a54483
--- /dev/null
+++ b/src/cognitive_agent/application/vehicle_profile.py
@@ -0,0 +1,166 @@
+"""Vehicle-specific seat calibration profiles."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+
+@dataclass(frozen=True)
+class SeatMotorProfile:
+ id: str
+ name: str
+ unit: str
+ range: float
+ reference_zero: str
+ positive_direction: str
+ hall_total_pulses: int
+ display_angle: dict[str, Any] | None = None
+
+ def delta_to_pct(self, delta: float) -> float:
+ if self.range <= 0:
+ raise ValueError(f"motor {self.id} range must be positive")
+ return float(delta) / self.range * 100
+
+ def display_angle_for_pct(self, position_pct: int) -> float | None:
+ if not self.display_angle:
+ return None
+ zero = float(self.display_angle["zero_deg"])
+ maximum = float(self.display_angle["max_deg"])
+ return zero + (maximum - zero) * _clamp_pct(position_pct) / 100
+
+ def position_pct_for_display_angle(self, angle_deg: int | float) -> int:
+ if not self.display_angle:
+ raise ValueError(f"motor {self.id} does not define a display angle range")
+ zero = float(self.display_angle["zero_deg"])
+ maximum = float(self.display_angle["max_deg"])
+ if maximum <= zero:
+ raise ValueError(f"motor {self.id} display angle range must be positive")
+ normalized = (float(angle_deg) - zero) / (maximum - zero)
+ return _clamp_pct(round(normalized * 100))
+
+ def contains_display_angle(self, angle_deg: int | float) -> bool:
+ if not self.display_angle:
+ return False
+ zero = float(self.display_angle["zero_deg"])
+ maximum = float(self.display_angle["max_deg"])
+ return zero <= float(angle_deg) <= maximum
+
+ def public_dict(self) -> dict[str, Any]:
+ payload: dict[str, Any] = {
+ "id": self.id,
+ "name": self.name,
+ "unit": self.unit,
+ "range": _public_number(self.range),
+ "reference_zero": self.reference_zero,
+ "positive_direction": self.positive_direction,
+ "hall_total_pulses": self.hall_total_pulses,
+ }
+ if self.display_angle is not None:
+ payload["display_angle"] = dict(self.display_angle)
+ return payload
+
+
+@dataclass(frozen=True)
+class VehicleProfile:
+ id: str
+ name: str
+ seat_motors: dict[str, SeatMotorProfile]
+
+ def relative_target_pct(
+ self,
+ axis: str,
+ *,
+ current_pct: int | float,
+ delta: int | float,
+ direction: str,
+ ) -> int:
+ motor = self.seat_motors[axis]
+ delta_pct = motor.delta_to_pct(float(delta))
+ if direction == "decrease":
+ delta_pct = -delta_pct
+ elif direction != "increase":
+ raise ValueError(f"unsupported relative direction: {direction}")
+ return _clamp_pct(round(float(current_pct) + delta_pct))
+
+ def display_angle_for_pct(self, axis: str, position_pct: int) -> float | None:
+ return self.seat_motors[axis].display_angle_for_pct(position_pct)
+
+ def public_dict(self) -> dict[str, Any]:
+ return {
+ "id": self.id,
+ "name": self.name,
+ "seat": {
+ "motors": {
+ key: motor.public_dict()
+ for key, motor in self.seat_motors.items()
+ }
+ },
+ }
+
+
+class VehicleProfileRegistry:
+ def __init__(self, profile_root: Path) -> None:
+ self.profile_root = profile_root
+ self._profiles = self._load_profiles(profile_root)
+
+ def get(self, vehicle_id: str | None) -> VehicleProfile:
+ resolved = vehicle_id or self.default().id
+ try:
+ return self._profiles[resolved]
+ except KeyError as exc:
+ raise ValueError(f"unknown vehicle profile: {resolved}") from exc
+
+ def default(self) -> VehicleProfile:
+ if "denza_n7" in self._profiles:
+ return self._profiles["denza_n7"]
+ try:
+ return next(iter(self._profiles.values()))
+ except StopIteration as exc:
+ raise ValueError(f"no vehicle profiles found in {self.profile_root}") from exc
+
+ def public_profiles(self) -> list[dict[str, Any]]:
+ return [profile.public_dict() for profile in self._profiles.values()]
+
+ @staticmethod
+ def _load_profiles(profile_root: Path) -> dict[str, VehicleProfile]:
+ profiles: dict[str, VehicleProfile] = {}
+ for path in sorted(profile_root.glob("*.yaml")):
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
+ profile = _parse_vehicle_profile(data)
+ profiles[profile.id] = profile
+ return profiles
+
+
+def _parse_vehicle_profile(data: dict[str, Any]) -> VehicleProfile:
+ seat = data.get("seat") or {}
+ motors = seat.get("motors") or {}
+ seat_motors = {
+ str(axis): SeatMotorProfile(
+ id=str(axis),
+ name=str(payload["name"]),
+ unit=str(payload["unit"]),
+ range=float(payload["range"]),
+ reference_zero=str(payload["reference_zero"]),
+ positive_direction=str(payload["positive_direction"]),
+ hall_total_pulses=int(payload["hall_total_pulses"]),
+ display_angle=payload.get("display_angle"),
+ )
+ for axis, payload in motors.items()
+ }
+ return VehicleProfile(
+ id=str(data["id"]),
+ name=str(data["name"]),
+ seat_motors=seat_motors,
+ )
+
+
+def _clamp_pct(value: int | float) -> int:
+ return max(0, min(100, int(value)))
+
+
+def _public_number(value: float) -> int | float:
+ return int(value) if value.is_integer() else value
diff --git a/src/cognitive_agent/infrastructure/llm/openai_responses.py b/src/cognitive_agent/infrastructure/llm/openai_responses.py
index a40f006..3f67abe 100755
--- a/src/cognitive_agent/infrastructure/llm/openai_responses.py
+++ b/src/cognitive_agent/infrastructure/llm/openai_responses.py
@@ -129,6 +129,13 @@ and current state. The absence of a named preset is never, by itself, an ambigui
assumptions in decision_summary. Safety validation and hardware eligibility are downstream concerns;
do not withhold a dry-run candidate merely because a capability is marked high risk.
+Runtime context may include mode_profiles. These are trusted long-term mode configuration data,
+not new capabilities. If the user names one of those profiles, the deterministic runtime will inject
+that profile's operations after planning. In that case, return only the user's explicit additional
+goals that are not already represented by the named profile, and mention the named profile in
+decision_summary. If the user names no existing profile, synthesize a temporary plan from atomic
+capabilities as usual.
+
Return every understood goal in requests using an exact capability name from the registry. Use
target_scope and operation to preserve the user's intent. Put concrete capability parameters in the
parameters list when they are known or defensibly inferred. Do not drop catalog_only requests; the
diff --git a/src/cognitive_agent/server.py b/src/cognitive_agent/server.py
index 8b47c87..0bcaa2c 100755
--- a/src/cognitive_agent/server.py
+++ b/src/cognitive_agent/server.py
@@ -145,9 +145,16 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
str(payload.get("provider", "auto")),
str(payload["conversation_id"]) if payload.get("conversation_id") else None,
str(payload.get("execution_mode", "dry_run")),
- str(payload["approval_token"])
- if payload.get("approval_token")
- else None,
+ approval_token=(
+ str(payload["approval_token"])
+ if payload.get("approval_token")
+ else None
+ ),
+ vehicle_id=(
+ str(payload["vehicle_id"])
+ if payload.get("vehicle_id")
+ else None
+ ),
)
self._json(result, HTTPStatus.CREATED)
except HardwareSafetyError as exc:
diff --git a/tests/e2e/test_seat_web_api.py b/tests/e2e/test_seat_web_api.py
index 1bc5c7b..f345bfd 100755
--- a/tests/e2e/test_seat_web_api.py
+++ b/tests/e2e/test_seat_web_api.py
@@ -108,6 +108,8 @@ class SeatWebApiTests(unittest.TestCase):
self.assertIn('data-twin-function="heating"', html)
self.assertIn('class="twin-level-meter"', html)
self.assertIn('id="presetIndicator"', html)
+ self.assertIn('id="vehicleSelect"', html)
+ self.assertIn('id="saveModeToggle"', html)
self.assertIn('data-domain-panel="climate"', html)
self.assertIn('data-domain-panel="roof"', html)
self.assertIn('data-domain-panel="ambient_light"', html)
@@ -118,6 +120,20 @@ class SeatWebApiTests(unittest.TestCase):
self.assertGreaterEqual(runtime["matrix"]["messages"], 25)
self.assertGreaterEqual(runtime["matrix"]["signals"], 128)
self.assertEqual(runtime["motion_position_range_pct"], [0, 100])
+ self.assertEqual(runtime["default_vehicle_id"], "denza_n7")
+ self.assertEqual(runtime["vehicle"]["name"], "腾势 N7")
+ self.assertIn(
+ "denza_n7",
+ {vehicle["id"] for vehicle in runtime["vehicles"]},
+ )
+ self.assertIn(
+ "zero_gravity",
+ {mode["id"] for mode in runtime["modes"]},
+ )
+ self.assertEqual(
+ runtime["vehicle"]["seat"]["motors"]["longitudinal"]["range"],
+ 215,
+ )
capability_names = {item["name"] for item in runtime["capabilities"]}
self.assertIn("seat.save_posture_preset", capability_names)
self.assertIn("seat.apply_posture_preset", capability_names)
@@ -135,6 +151,7 @@ class SeatWebApiTests(unittest.TestCase):
"utterance": "主驾有点热,有点闷",
"provider": "local",
"conversation_id": "conv-e2e-browser",
+ "vehicle_id": "denza_n7",
}
).encode("utf-8"),
)
@@ -146,6 +163,7 @@ class SeatWebApiTests(unittest.TestCase):
self.assertEqual(result["command_plan"]["kind"], "CommandPlan")
self.assertEqual(result["execution_report"]["spec"]["mode"], "dry_run")
self.assertEqual(result["conversation_id"], "conv-e2e-browser")
+ self.assertEqual(result["vehicle"]["id"], "denza_n7")
self.assertEqual(result["actuator_state"]["driver"]["ventilation"]["level"], 1)
def test_hardware_request_is_rejected_when_server_gate_is_locked(self) -> None:
diff --git a/tests/integration/test_multidomain_service.py b/tests/integration/test_multidomain_service.py
index 1ed95d9..157cb92 100755
--- a/tests/integration/test_multidomain_service.py
+++ b/tests/integration/test_multidomain_service.py
@@ -6,6 +6,7 @@ from pathlib import Path
from unittest.mock import patch
from cognitive_agent.application.domain_runtime import ConfiguredDomainRuntime
+from cognitive_agent.application.mode_profile import ModeProfileRegistry
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
@@ -219,6 +220,260 @@ class MultiDomainServiceTests(unittest.TestCase):
self.assertEqual(result["domain_state"]["roof"]["transmittance_pct"], 20)
self.assertEqual(result["conversation_turn"], 2)
+ def test_named_mode_profile_executes_without_model_expansion(self) -> None:
+ model_output = {
+ "detected_domains": ["seat"],
+ "requests": [],
+ "ambiguities": [],
+ "decision_summary": ["用户要求调用已有零重力模式。"],
+ "confidence": 0.94,
+ }
+ with patch.object(self.service.openai, "interpret", return_value=model_output):
+ result = self.service.process(
+ "副驾驶进入零重力模式",
+ "openai",
+ "conv-zero-gravity-mode",
+ vehicle_id="denza_n7",
+ )
+
+ self.assertEqual(result["status"], "succeeded")
+ self.assertEqual(result["matched_modes"][0]["id"], "zero_gravity")
+ operations = result["function_config"]["spec"]["operations"]
+ by_capability = {item["capability"]: item for item in operations}
+ self.assertEqual(
+ by_capability["seat.set_longitudinal_position"]["parameters"],
+ {"position_pct": 0},
+ )
+ self.assertEqual(
+ by_capability["seat.set_cushion_tilt"]["parameters"],
+ {"position_pct": 100},
+ )
+ self.assertEqual(
+ by_capability["seat.set_backrest_recline"]["parameters"],
+ {"position_pct": 81, "angle_deg": 150},
+ )
+ self.assertEqual(
+ by_capability["seat.set_legrest_height"]["parameters"],
+ {"position_pct": 87, "angle_deg": 163},
+ )
+
+ def test_named_mode_profile_combines_with_temporary_roof_request(self) -> None:
+ model_output = {
+ "detected_domains": ["roof"],
+ "requests": [
+ {
+ "domain": "roof",
+ "capability": "roof.set_transmittance",
+ "target_scope": "vehicle",
+ "operation": "set",
+ "parameters": [{"name": "transmittance_pct", "value": 20}],
+ "reason": "用户同时要求天幕调暗。",
+ }
+ ],
+ "ambiguities": [],
+ "decision_summary": ["零重力之外还需设置天幕。"],
+ "confidence": 0.96,
+ }
+ with patch.object(self.service.openai, "interpret", return_value=model_output):
+ result = self.service.process(
+ "副驾零重力模式,天幕透光率调到20%",
+ "openai",
+ "conv-mode-plus-roof",
+ vehicle_id="denza_n7",
+ )
+
+ self.assertEqual(result["status"], "succeeded")
+ self.assertEqual(set(result["function_configs"]), {"seat", "roof"})
+ self.assertEqual(result["scene_config"]["kind"], "SceneConfig")
+ self.assertEqual(result["domain_state"]["roof"]["transmittance_pct"], 20)
+ self.assertIn(
+ "seat.set_backrest_recline",
+ {
+ item["capability"]
+ for item in result["function_configs"]["seat"]["spec"]["operations"]
+ },
+ )
+
+ def test_reference_mode_library_modes_execute_for_denza(self) -> None:
+ model_output = {
+ "detected_domains": ["seat"],
+ "requests": [],
+ "ambiguities": [],
+ "decision_summary": ["用户要求调用已有长期模式。"],
+ "confidence": 0.95,
+ }
+ utterances = [
+ "副驾驶进入零重力模式",
+ "副驾驶进入巡航沙发模式",
+ "副驾驶进入休闲躺椅模式",
+ "副驾驶进入小憩模式",
+ "副驾驶进入轻松进出模式",
+ "副驾驶进入宝宝午睡模式",
+ "副驾驶进入办公模式",
+ "副驾驶进入冥想空间模式",
+ "副驾驶进入电感沙滩椅",
+ "副驾驶进入女王副驾模式",
+ "副驾驶进入小腿解压悬浮模式",
+ ]
+
+ with patch.object(self.service.openai, "interpret", return_value=model_output):
+ for index, utterance in enumerate(utterances):
+ with self.subTest(utterance=utterance):
+ result = self.service.process(
+ utterance,
+ "openai",
+ f"conv-reference-mode-{index}",
+ vehicle_id="denza_n7",
+ )
+
+ self.assertEqual(result["status"], "succeeded")
+ self.assertTrue(result["matched_modes"])
+ self.assertTrue(
+ result["function_config"] or result["function_configs"]
+ )
+
+ def test_save_recent_successful_actions_as_long_term_mode_profile(self) -> None:
+ self.service.mode_profiles = ModeProfileRegistry(
+ self.service.artifact_root / "modes"
+ )
+ model_output = {
+ "detected_domains": ["roof"],
+ "requests": [
+ {
+ "domain": "roof",
+ "capability": "roof.set_transmittance",
+ "target_scope": "vehicle",
+ "operation": "set",
+ "parameters": [{"name": "transmittance_pct", "value": 35}],
+ "reason": "保存前先形成一个可复用动作。",
+ }
+ ],
+ "ambiguities": [],
+ "decision_summary": ["天幕透光率设为35%。"],
+ "confidence": 0.96,
+ }
+ with patch.object(self.service.openai, "interpret", return_value=model_output):
+ first = self.service.process(
+ "天幕透光率调到35%",
+ "openai",
+ "conv-save-mode-profile",
+ vehicle_id="denza_n7",
+ )
+
+ saved = self.service.process(
+ "这个模式不错,固化为露营模式",
+ "local",
+ "conv-save-mode-profile",
+ vehicle_id="denza_n7",
+ )
+
+ self.assertEqual(first["status"], "succeeded")
+ self.assertEqual(saved["status"], "succeeded")
+ self.assertIsNone(saved["command_plan"])
+ self.assertEqual(saved["mode_profile"]["name"], "露营模式")
+ telemetry = saved["execution_report"]["spec"]["results"][0]["telemetry"]
+ self.assertEqual(telemetry["operation"], "save_mode_profile")
+ self.assertFalse(telemetry["can_command_emitted"])
+
+ registry = ModeProfileRegistry(self.service.artifact_root / "modes")
+ matched = registry.match_text("进入露营模式", vehicle_id="denza_n7")
+ requests = registry.requests_for_profiles(matched)
+ self.assertEqual(requests[0]["capability"], "roof.set_transmittance")
+ self.assertEqual(
+ requests[0]["parameters"],
+ [{"name": "transmittance_pct", "value": 35}],
+ )
+ self.assertEqual(
+ registry.match_text("进入露营模式", vehicle_id="other_vehicle"),
+ [],
+ )
+
+ def test_update_and_restore_long_term_mode_profile_are_control_plane_only(self) -> None:
+ self.service.mode_profiles = ModeProfileRegistry(
+ self.service.artifact_root / "modes"
+ )
+ profile = self.service.mode_profiles.save_mode(
+ "露营模式",
+ [
+ {
+ "domain": "roof",
+ "capability": "roof.set_transmittance",
+ "target_scope": "vehicle",
+ "operation": "set",
+ "parameters": {"transmittance_pct": 20},
+ "reason": "原始天幕目标。",
+ }
+ ],
+ vehicle_id="denza_n7",
+ )
+ model_output = {
+ "detected_domains": ["roof"],
+ "requests": [
+ {
+ "domain": "roof",
+ "capability": "roof.set_transmittance",
+ "target_scope": "vehicle",
+ "operation": "set",
+ "parameters": [{"name": "transmittance_pct", "value": 50}],
+ "reason": "用户修改长期模式天幕目标。",
+ }
+ ],
+ "ambiguities": [],
+ "decision_summary": ["修改露营模式天幕目标。"],
+ "confidence": 0.96,
+ }
+
+ with patch.object(self.service.openai, "interpret", return_value=model_output):
+ updated = self.service.process(
+ "修改露营模式,天幕透光率改到50%",
+ "openai",
+ "conv-update-mode-profile",
+ execution_mode="hardware",
+ vehicle_id="denza_n7",
+ )
+
+ self.assertEqual(updated["status"], "succeeded")
+ self.assertIsNone(updated["command_plan"])
+ update_telemetry = updated["execution_report"]["spec"]["results"][0][
+ "telemetry"
+ ]
+ self.assertEqual(update_telemetry["operation"], "update_mode_profile")
+ self.assertFalse(update_telemetry["can_command_emitted"])
+ self.assertTrue(Path(profile.source).with_suffix(".yaml.initial.bak").is_file())
+
+ registry = ModeProfileRegistry(self.service.artifact_root / "modes")
+ requests = registry.requests_for_profiles(
+ registry.match_text("进入露营模式", vehicle_id="denza_n7")
+ )
+ self.assertEqual(
+ requests[0]["parameters"],
+ [{"name": "transmittance_pct", "value": 50}],
+ )
+
+ restored = self.service.process(
+ "恢复露营模式初始配置",
+ "local",
+ "conv-update-mode-profile",
+ execution_mode="hardware",
+ vehicle_id="denza_n7",
+ )
+
+ self.assertEqual(restored["status"], "succeeded")
+ self.assertIsNone(restored["command_plan"])
+ restore_telemetry = restored["execution_report"]["spec"]["results"][0][
+ "telemetry"
+ ]
+ self.assertEqual(restore_telemetry["operation"], "restore_mode_profile")
+ self.assertFalse(restore_telemetry["can_command_emitted"])
+ restored_registry = ModeProfileRegistry(self.service.artifact_root / "modes")
+ restored_requests = restored_registry.requests_for_profiles(
+ restored_registry.match_text("进入露营模式", vehicle_id="denza_n7")
+ )
+ self.assertEqual(
+ restored_requests[0]["parameters"],
+ [{"name": "transmittance_pct", "value": 20}],
+ )
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/unit/test_mode_profile.py b/tests/unit/test_mode_profile.py
new file mode 100644
index 0000000..b3ffac0
--- /dev/null
+++ b/tests/unit/test_mode_profile.py
@@ -0,0 +1,265 @@
+from __future__ import annotations
+
+import tempfile
+import unittest
+from pathlib import Path
+
+import yaml
+
+from cognitive_agent.application.mode_profile import ModeProfileRegistry
+from cognitive_agent.application.seat_service import REPOSITORY_ROOT
+
+
+class ModeProfileRegistryTests(unittest.TestCase):
+ def test_zero_gravity_profile_matches_alias_and_builds_requests(self) -> None:
+ registry = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes")
+
+ matched = registry.match_text("副驾驶进入零重力模式", vehicle_id="denza_n7")
+ self.assertEqual([profile.id for profile in matched], ["zero_gravity"])
+ self.assertEqual(matched[0].vehicle_ids, ("denza_n7",))
+
+ requests = registry.requests_for_profiles(matched)
+ self.assertGreaterEqual(len(requests), 4)
+ self.assertEqual(requests[0]["domain"], "seat")
+ self.assertEqual(requests[0]["capability"], "seat.set_longitudinal_position")
+ self.assertEqual(requests[0]["target_scope"], "passenger")
+ self.assertIn(
+ {"name": "position_pct", "value": 0},
+ requests[0]["parameters"],
+ )
+ by_capability = {request["capability"]: request for request in requests}
+ self.assertEqual(
+ by_capability["seat.set_cushion_tilt"]["parameters"],
+ [{"name": "position_pct", "value": 100}],
+ )
+ self.assertEqual(
+ by_capability["seat.set_backrest_recline"]["parameters"],
+ [
+ {"name": "position_pct", "value": 81},
+ {"name": "angle_deg", "value": 150},
+ ],
+ )
+ self.assertEqual(
+ by_capability["seat.set_legrest_height"]["parameters"],
+ [
+ {"name": "position_pct", "value": 87},
+ {"name": "angle_deg", "value": 163},
+ ],
+ )
+
+ public = registry.public_modes()
+ self.assertIn("零重力模式", {item["name"] for item in public})
+ self.assertNotIn(
+ "zero_gravity",
+ {profile.id for profile in registry.match_text("副驾驶进入零重力模式", vehicle_id="other_vehicle")},
+ )
+
+ def test_reference_modes_are_denza_specific(self) -> None:
+ registry = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes")
+
+ expected_ids = {
+ "zero_gravity",
+ "cruise_sofa",
+ "leisure_lounger",
+ "nap",
+ "easy_entry_exit",
+ "baby_nap",
+ "office",
+ "meditation_space",
+ "electric_beach_chair",
+ "queen_passenger_driving",
+ "calf_decompression_float",
+ }
+ denza_modes = registry.public_modes(vehicle_id="denza_n7")
+ denza_ids = {mode["id"] for mode in denza_modes}
+
+ self.assertTrue(expected_ids.issubset(denza_ids))
+ self.assertTrue(
+ expected_ids.isdisjoint(
+ {mode["id"] for mode in registry.public_modes(vehicle_id="other_vehicle")}
+ )
+ )
+ self.assertEqual(
+ registry.match_text("进入办公模式", vehicle_id="denza_n7")[0].id,
+ "office",
+ )
+ self.assertEqual(
+ registry.match_text("进入小腿解压悬浮模式", vehicle_id="denza_n7")[0].id,
+ "calf_decompression_float",
+ )
+
+ def test_common_modes_are_visible_with_vehicle_specific_overrides(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ common = root / "common"
+ denza = root / "vehicles" / "denza_n7"
+ other = root / "vehicles" / "other_vehicle"
+ common.mkdir(parents=True)
+ denza.mkdir(parents=True)
+ other.mkdir(parents=True)
+ (common / "relax.yaml").write_text(
+ """
+id: relax
+name: 放松模式
+aliases:
+ - relax
+description: 公共放松模式。
+operations:
+ - domain: roof
+ capability: roof.set_transmittance
+ target_scope: vehicle
+ operation: set
+ parameters:
+ transmittance_pct: 45
+""".strip()
+ + "\n",
+ encoding="utf-8",
+ )
+ (denza / "relax.yaml").write_text(
+ """
+id: relax
+name: N7 放松模式
+aliases:
+ - relax
+description: N7 专属放松模式。
+scope:
+ vehicle_ids:
+ - denza_n7
+operations:
+ - domain: roof
+ capability: roof.set_transmittance
+ target_scope: vehicle
+ operation: set
+ parameters:
+ transmittance_pct: 20
+""".strip()
+ + "\n",
+ encoding="utf-8",
+ )
+ (other / "nap.yaml").write_text(
+ """
+id: nap
+name: 午休模式
+description: 其他车型专属午休模式。
+operations:
+ - domain: roof
+ capability: roof.set_transmittance
+ target_scope: vehicle
+ operation: set
+ parameters:
+ transmittance_pct: 10
+""".strip()
+ + "\n",
+ encoding="utf-8",
+ )
+
+ registry = ModeProfileRegistry(root)
+
+ denza_matches = registry.match_text("进入放松模式", vehicle_id="denza_n7")
+ other_matches = registry.match_text("进入放松模式", vehicle_id="other_vehicle")
+
+ self.assertEqual([profile.name for profile in denza_matches], ["N7 放松模式"])
+ self.assertEqual([profile.name for profile in other_matches], ["放松模式"])
+ self.assertEqual(
+ {profile.id for profile in registry.match_text("进入午休模式", vehicle_id="denza_n7")},
+ set(),
+ )
+ self.assertEqual(
+ {mode["id"] for mode in registry.public_modes(vehicle_id="denza_n7")},
+ {"relax"},
+ )
+
+ def test_save_mode_profile_persists_yaml_and_reloads(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ registry = ModeProfileRegistry(root)
+ profile = registry.save_mode(
+ "露营模式",
+ [
+ {
+ "domain": "roof",
+ "capability": "roof.set_transmittance",
+ "target_scope": "vehicle",
+ "operation": "set",
+ "parameters": {"transmittance_pct": 20},
+ "reason": "保存最近一次天幕目标。",
+ }
+ ],
+ vehicle_id="denza_n7",
+ )
+
+ saved_path = Path(profile.source)
+ self.assertTrue(saved_path.is_file())
+ self.assertEqual(
+ saved_path.relative_to(root).parts[:3],
+ ("vehicles", "denza_n7", "user"),
+ )
+ document = yaml.safe_load(saved_path.read_text(encoding="utf-8"))
+ self.assertEqual(document["name"], "露营模式")
+ self.assertEqual(document["scope"]["vehicle_ids"], ["denza_n7"])
+ self.assertEqual(document["operations"][0]["domain"], "roof")
+
+ reloaded = ModeProfileRegistry(root)
+ matched = reloaded.match_text("进入露营模式", vehicle_id="denza_n7")
+ self.assertEqual([item.name for item in matched], ["露营模式"])
+ self.assertEqual(
+ reloaded.match_text("进入露营模式", vehicle_id="other_vehicle"),
+ [],
+ )
+ requests = reloaded.requests_for_profiles(matched)
+ self.assertEqual(requests[0]["capability"], "roof.set_transmittance")
+ self.assertEqual(
+ requests[0]["parameters"],
+ [{"name": "transmittance_pct", "value": 20}],
+ )
+
+ def test_update_mode_profile_backs_up_initial_yaml_and_restores(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ registry = ModeProfileRegistry(root)
+ profile = registry.save_mode(
+ "露营模式",
+ [
+ {
+ "domain": "roof",
+ "capability": "roof.set_transmittance",
+ "target_scope": "vehicle",
+ "operation": "set",
+ "parameters": {"transmittance_pct": 20},
+ "reason": "原始天幕目标。",
+ }
+ ],
+ vehicle_id="denza_n7",
+ )
+
+ updated = registry.update_mode(
+ profile,
+ [
+ {
+ "domain": "roof",
+ "capability": "roof.set_transmittance",
+ "target_scope": "vehicle",
+ "operation": "set",
+ "parameters": {"transmittance_pct": 50},
+ "reason": "用户修改天幕目标。",
+ }
+ ],
+ )
+
+ self.assertTrue(Path(profile.source).with_suffix(".yaml.initial.bak").is_file())
+ requests = registry.requests_for_profiles([updated])
+ self.assertEqual(
+ requests[0]["parameters"],
+ [{"name": "transmittance_pct", "value": 50}],
+ )
+
+ restored = registry.restore_initial_mode(updated)
+ restored_requests = registry.requests_for_profiles([restored])
+ self.assertEqual(
+ restored_requests[0]["parameters"],
+ [{"name": "transmittance_pct", "value": 20}],
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/test_vehicle_profile.py b/tests/unit/test_vehicle_profile.py
new file mode 100644
index 0000000..e867b13
--- /dev/null
+++ b/tests/unit/test_vehicle_profile.py
@@ -0,0 +1,72 @@
+from __future__ import annotations
+
+import unittest
+from pathlib import Path
+
+from cognitive_agent.application.vehicle_profile import VehicleProfileRegistry
+
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+class VehicleProfileTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.registry = VehicleProfileRegistry(ROOT / "config" / "vehicles")
+
+ def test_loads_denza_n7_seat_motor_calibration(self) -> None:
+ profile = self.registry.get("denza_n7")
+
+ self.assertEqual(profile.id, "denza_n7")
+ self.assertEqual(profile.name, "腾势 N7")
+ self.assertEqual(set(profile.seat_motors), {
+ "longitudinal",
+ "cushion_tilt",
+ "backrest_recline",
+ "legrest_height",
+ })
+ self.assertEqual(profile.seat_motors["longitudinal"].range, 215)
+ self.assertEqual(profile.seat_motors["longitudinal"].hall_total_pulses, 480)
+ self.assertEqual(profile.seat_motors["longitudinal"].reference_zero, "rearmost")
+ self.assertEqual(profile.seat_motors["backrest_recline"].range, 68)
+ self.assertEqual(profile.seat_motors["backrest_recline"].hall_total_pulses, 917)
+ self.assertEqual(profile.seat_motors["legrest_height"].range, 78)
+ self.assertEqual(profile.seat_motors["legrest_height"].hall_total_pulses, 647)
+
+ def test_converts_relative_physical_delta_to_absolute_percent(self) -> None:
+ profile = self.registry.get("denza_n7")
+
+ self.assertEqual(
+ profile.relative_target_pct("longitudinal", current_pct=50, delta=50, direction="increase"),
+ 73,
+ )
+ self.assertEqual(
+ profile.relative_target_pct("backrest_recline", current_pct=20, delta=10, direction="increase"),
+ 35,
+ )
+ self.assertEqual(
+ profile.relative_target_pct("legrest_height", current_pct=10, delta=10, direction="decrease"),
+ 0,
+ )
+
+ def test_exposes_display_angle_ranges_for_frontend(self) -> None:
+ profile = self.registry.get("denza_n7")
+ public = profile.public_dict()
+
+ backrest = public["seat"]["motors"]["backrest_recline"]
+ legrest = public["seat"]["motors"]["legrest_height"]
+ self.assertEqual(backrest["display_angle"], {
+ "kind": "included_angle",
+ "zero_deg": 95,
+ "max_deg": 163,
+ "provisional": True,
+ })
+ self.assertEqual(legrest["display_angle"], {
+ "kind": "included_angle",
+ "zero_deg": 95,
+ "max_deg": 173,
+ "provisional": True,
+ })
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tmp/reference.md b/tmp/reference.md
new file mode 100644
index 0000000..31c099b
--- /dev/null
+++ b/tmp/reference.md
@@ -0,0 +1,303 @@
+> 长期库处理说明(2026-07-30):本文件中所有模式都包含腾势 N7 座椅四电机姿态参数,因此本次均判定为 N7 车型专属模式,写入 `config/modes/vehicles/denza_n7/`;公共库本次未新增。未标注的座椅、温度、风量、内外循环、天幕遮阳帘、整车氛围灯颜色/亮度等项目已按现有能力落库。标注 `【暂不支持】` 或 `【部分支持】` 的项目未完整落库。
+
+# 零重力模式
+## 座椅
+
+
+1、水平电机:0mm
+
+2、坐盆电机:100%
+
+3、靠背电机:55°
+
+4、 腿托电机:68°
+
+
+
+# 巡航沙发模式
+## 座椅
+
+1、水平电机:105mm
+
+2、坐盆电机:30%
+
+3、靠背电机:30°
+
+4、 腿托电机:38°
+
+
+
+# 休闲躺椅模式
+## 座椅
+1、水平电机:105mm
+
+2、坐盆电机:30%
+
+3、靠背电机:45°
+
+4、 腿托电机:55°
+
+
+## 空调
+1. 二排右侧专属温区自动调至 24–26℃舒适区间
+
+2. 出风口自动切换至吹脚 + 吹腰模式,风量中档【部分支持:现有空调能力只支持 face/feet 出风,已落库吹脚;腰部出风没有 CAN 能力。】
+
+3. 自动切换外循环,避免密闭缺氧;冬季自动开启座椅 + 腿托加热,夏季自动开启座椅通风【部分支持:外循环已落库;季节条件判断、腿托加热、按季节自动切换座椅加热/通风暂不支持。】
+
+## 天幕
+1. 全景天幕电动遮阳帘完全关闭
+
+2. 二排右侧侧窗电动遮阳帘自动升起,隔绝强光【暂不支持:现有 roof 能力只覆盖全景天幕遮阳帘/调光天幕,没有侧窗遮阳帘 CAN 能力。】
+
+3. 后排隐私玻璃配合遮光,降低光线刺激便于休息【暂不支持:没有后排隐私玻璃/侧窗调光 CAN 能力。】
+
+## 音响
+1. 后排娱乐屏自动调暗亮度,或切换至静态舒缓画面【暂不支持:当前没有屏幕亮度/壁纸/娱乐屏控制能力。】
+
+2. 整车音响音量自动降低,屏蔽尖锐音效;可自动切换白噪音 / 静谧声场【暂不支持:当前没有音响音量、音源、声场或白噪音 CAN 能力。】
+
+3. 头枕音响切换柔和低频模式,减少震动扰眠【暂不支持:当前没有头枕音响控制能力。】
+
+## 氛围灯
+1. 全车阅读灯全部熄灭【暂不支持:当前 ambient_light 只覆盖整车氛围灯,不覆盖阅读灯。】
+
+2. 内饰氛围灯切换低亮度暖光(色温 3000K),亮度降至 20%,不刺眼
+
+3. 车门、扶手处辅助氛围灯减弱【暂不支持:当前氛围灯能力是整车级颜色/亮度/模式,没有门板、扶手分区控制。】
+
+
+
+# 小憩模式
+
+## 座椅
+
+1、水平电机:215mm
+
+2、坐盆电机:0
+
+3、靠背电机:0
+
+4、 腿托电机:0
+
+## 空调
+
+1. 自动设定 22~24℃恒温,低风静音档位
+
+2. 出风口自动调至水平上吹,避免直吹人体【暂不支持:现有空调出风模式只有 face/feet,不能表达水平上吹或避免直吹。】
+
+3. 内外循环自动切换内循环,兼顾静音与保温;电池电量充足时关闭增程器,杜绝发动机震动噪音理想汽车【部分支持:内循环已落库;电池电量判断、增程器关闭、发动机震动噪音控制暂不支持。】
+
+4. 带冰箱车型:冰箱维持低功耗恒温运行【暂不支持:当前没有车载冰箱控制能力。】
+
+## 天幕
+1. 全车车窗:自动完全升窗关闭,杜绝风噪、漏光、蚊虫【暂不支持:当前没有车窗升降 CAN 能力。】
+
+2. 全景天幕电动遮阳帘:前后天幕遮阳帘全关
+
+3. 二排侧窗电动隐私遮阳帘:全部关闭,完全遮光打造暗室环境理想汽车【暂不支持:当前没有二排侧窗隐私遮阳帘控制能力。】
+
+4. 门锁执行电机:车辆自动全车落锁;临时屏蔽靠近解锁、无钥匙进入,防止外人开门打扰休息【暂不支持:当前没有门锁、靠近解锁或无钥匙进入策略控制能力。】
+
+## 音响
+1. 中控仪表、后排娱乐屏:屏幕切换星空静谧壁纸,屏幕大幅降亮度,减少光线刺激理想汽车【暂不支持:当前没有仪表/屏幕壁纸或亮度控制能力。】
+
+2. 音响系统:自动开启潮汐 / 自然白噪音(海浪、森林、雨声),音量低噪柔和;导航播报、电话铃声临时静音(勿扰逻辑)【暂不支持:当前没有音响音源/音量、导航播报、电话勿扰控制能力。】
+
+3. 内置定时闹钟计时模块:支持 15/30/60 分钟定时,时间到自动播放舒缓音乐唤醒用户【暂不支持:当前 mode profile 不支持定时器、闹钟或到时自动执行后续动作。】
+## 氛围灯
+
+车内全部阅读灯、门板氛围灯、中控氛围灯:亮度降至最低 / 直接关闭【部分支持:整车氛围灯关闭已落库;阅读灯、门板/中控分区亮度暂不支持。】
+
+# 轻松进出模式
+
+
+
+## 上下车门:
+### 座椅
+1、水平电机:0mm
+
+2、坐盆电机:0%
+
+3、靠背电机:0°
+
+4、腿托电机:0°
+
+## 关门(坐好):【暂不支持:当前长期模式库是静态动作集合,没有门状态触发和关门后自动恢复第二阶段;已落库“上下车门”进出姿态。】
+### 座椅
+1、水平电机:105mm
+
+2、坐盆电机:0%
+
+3、靠背电机:0°
+
+4、 腿托电机:0°
+
+
+
+# 宝宝午睡模式
+## 座椅
+1、水平电机:40mm
+
+2、坐盆电机:30%
+
+3、靠背电机:28°
+
+4、腿托电机:0°
+
+## 空调
+
+1. 全车恒温锁定24℃,六温区分区,后排独立控温不跟随前排;【部分支持:全车 24℃ 已落库;六温区独立策略和后排不跟随前排的联动策略暂不支持。】
+
+2. 前排出风口上调水平送风,关闭后排出风直吹模式,避免冷风刺激宝宝;【暂不支持:现有空调出风模式只有 face/feet,不能表达水平送风、关闭后排直吹或防直吹策略。】
+
+3. 风量强制最低静音 1 档,自动切换内循环,开启森林氧吧空气净化;【部分支持:风量 1 档和内循环已落库;森林氧吧/空气净化暂不支持。】
+## 天幕
+
+1. 全车车窗自动完全升窗;后排电子儿童锁一键上锁,内外拉手失效,退出模式自动解除;【暂不支持:当前没有车窗、儿童锁、门把手失效或退出模式自动解除控制能力。】
+
+2. 全景天幕遮阳帘90% 关闭,保留微弱漫反射柔光,避免全黑造成宝宝恐惧;后排侧隐私帘半关闭;【部分支持:全景天幕遮阳帘 90% 关闭已落库;后排侧隐私帘半关闭暂不支持。】
+## 音响
+
+1. 中控、后排娱乐屏大幅降亮度,切换纯色静谧壁纸,关闭动画强光;【暂不支持:当前没有屏幕亮度、壁纸或动画控制能力。】
+
+2. 全车多媒体静音、导航 / 来电播报屏蔽;仅主驾头枕音响保留极低音量预警;【暂不支持:当前没有多媒体静音、导航/来电播报屏蔽或头枕音响控制能力。】
+
+3. 自动播放低音量雨声 / 森林潮汐白噪音,隔绝路噪;后排扬声器音量锁死 60 分贝以内,保护儿童听力;【暂不支持:当前没有白噪音音源、后排扬声器音量锁定或分贝限制能力。】
+
+## 氛围灯
+1. 车内阅读灯全部熄灭;氛围灯亮度降至 5% 极暗暖白光,无频闪;【部分支持:5% 暖白氛围灯已落库;阅读灯关闭和无频闪策略暂不支持。】
+
+2. 外部日行灯、迎宾解锁灯光全部关闭,屏蔽夜间强光刺激;【暂不支持:当前没有外部日行灯或迎宾解锁灯控制能力。】
+
+# 办公模式
+## 座椅
+1、水平电机:60mm
+
+2、坐盆电机:22%
+
+3、靠背电机:15°
+
+4、 腿托电机:15°
+## 空调
+1. 座舱恒温锁定24.5℃,双区分区独立控温,办公座位专属送风;【部分支持:副驾 24.5℃ 已落库;双区独立策略和办公座位专属送风策略仅能部分通过 passenger 目标表达。】
+
+2. 出风口自动上调至水平上吹,完全关闭正面直吹头部、手部风道,避免冷风刺激导致打字手部僵硬;【部分支持:已用 passenger face 近似表达上吹;水平上吹、关闭头部/手部风道和防直吹策略暂不支持。】
+
+3. 风量中档静音档(区别休憩 1 档极低风量),兼顾空气循环与车内收音降噪;自动切换内循环,开启 PM2.5 / 负离子净化;【部分支持:风量中低档和内循环已落库;收音降噪、PM2.5/负离子净化暂不支持。】
+
+## 天幕
+1. 车车窗自动完全升窗,隔绝路噪、路人视线,保护办公隐私;【暂不支持:当前没有车窗升降控制能力。】
+
+2. 全景天幕遮阳帘70% 半关闭(核心差异:小憩 / 冥想 100% 全黑遮光);保留柔和漫反射自然光,防止长时间全黑屏幕反光伤眼,兼顾专注与护眼;后排侧隐私帘半关;【部分支持:全景天幕遮阳帘 70% 半关闭已落库;后排侧隐私帘半关暂不支持。】
+
+3. 全车车门自动落锁,临时屏蔽无钥匙靠近解锁、迎宾灯光,避免外人开门打断会议;【暂不支持:当前没有门锁、无钥匙进入或迎宾灯控制能力。】
+## 音响
+
+1. 全车音响进入会议静音优先逻辑
+
+- 导航、来电多媒体铃声全部静音勿扰;【暂不支持:当前没有导航、来电或多媒体勿扰控制能力。】
+
+- 关闭海浪 / 森林白噪音(休憩模式标配),仅保留极低音量主动降噪抵消路噪;【暂不支持:当前没有白噪音或主动降噪控制能力。】
+
+- 头枕独立麦克风自动开启降噪算法,适配线上腾讯会议、飞书、钉钉语音通话;【暂不支持:当前没有头枕麦克风、会议应用或降噪算法控制能力。】
+
+## 氛围灯
+屏幕亮度提高、冷白光氛围灯【部分支持:冷白光氛围灯已落库;屏幕亮度提高暂不支持。】
+
+
+
+# 冥想空间模式
+## 座椅
+1、水平电机:0mm
+
+2、坐盆电机:44%
+
+3、靠背电机:30°
+
+4、腿托电机:42°
+
+
+## 空调
+
+
+1. 多温区空调压缩机、鼓风机:恒温 22–24℃、低柔风量【部分支持:恒温 23℃ 和低风量已落库;多温区压缩机/鼓风机细分控制暂不支持。】
+
+2. 空调风门执行电机:自动切换内循环,隔绝外界异味
+## 天幕
+
+1. 四门车窗升降电机:默认自动升窗密闭(新版 OTA 可自定义关闭)【暂不支持:当前没有四门车窗升降或 OTA 自定义策略控制能力。】
+
+2. 全景天幕遮阳帘电机:完全闭合遮光
+
+3. 车门锁块、电吸门执行机构:保持闭锁强化隔音【暂不支持:当前没有门锁块或电吸门执行机构控制能力。】
+
+## 音响
+1. 全车杜比全景声扬声器阵列(含头枕音响单元)【暂不支持:当前没有扬声器阵列或头枕音响控制能力。】
+
+2. 车载 DSP 功放、主动降噪 ANC 模块:反向抵消风噪胎噪【暂不支持:当前没有 DSP 功放或 ANC 控制能力。】
+
+3. 中控大屏、副驾娱乐屏、后排悬浮屏:自动息屏 / 低亮度极简界面【暂不支持:当前没有屏幕息屏、亮度或界面控制能力。】
+
+4. 车载音源解码模块:加载自然白噪音 / 冥想纯音循环播放【暂不支持:当前没有车载音源或白噪音播放控制能力。】
+
+## 氛围灯
+中控大屏:进入冥想主题壁纸(森林 / 海浪 / 云雾低饱和画面),屏幕亮度调低,长时间无操作后进一步暗屏;【暂不支持:当前没有中控屏壁纸、亮度或暗屏策略控制能力。】
+
+自动大幅降低亮度(仅日常亮度 30% 左右),杜绝强光刺眼,整体柔光包裹座舱,无刺眼高光;
+
+绑定 5 大冥想主题自动切换,仅做极缓慢线性渐变,无快速变色、频闪,降低视觉刺激;【部分支持:矩阵定义的 meditation 模式和 30% 柔光已落库;5 大主题、缓慢渐变和无频闪策略暂不支持。】
+
+全车灯带同步统一色调:仪表台贯穿灯带、四门门板灯带、扶手箱灯带、中控侧边灯带全部联动【部分支持:当前只支持整车氛围灯统一颜色/亮度,不支持具体灯带分区联动。】
+
+# 电感沙滩椅
+## 座椅
+1、水平电机:0mm
+
+2、坐盆电机:0%
+
+3、靠背电机:68°
+
+4、腿托电机:43°
+## 空调
+1. 座舱锁定23℃恒温,双区分区独立控温,主副驾互不干扰;【部分支持:23℃ 恒温已落库;双区互不干扰策略需要更细的分区策略,当前未完整表达。】
+
+2. 出风口自动上调水平上吹,关闭直吹头部 / 腿部风道,杜绝冷风刺激;【部分支持:已用 passenger face 近似表达上吹;水平上吹、关闭头部/腿部风道和防直吹策略暂不支持。】
+## 天幕
+
+1. 全车车窗自动完全升窗,隔绝路噪、风沙、强光;【暂不支持:当前没有车窗升降控制能力。】
+
+2. 全景天幕遮阳帘 100% 完全关闭,后排侧隐私帘同步全关,无光静谧环境;【部分支持:全景天幕遮阳帘完全关闭已落库;后排侧隐私帘同步全关暂不支持。】
+
+## 音响
+全车音响自动播放低音量海浪 / 森林白噪音,ENC 主动降噪同步拉满,抵消路噪发动机噪音;导航、来电播报、多媒体铃声全部静音勿扰;【暂不支持:当前没有音响白噪音、ENC/主动降噪、导航/来电/多媒体勿扰控制能力。】
+
+
+
+# 女王副驾(行驶)
+## 座椅
+1、水平电机:60mm
+
+2、坐盆电机:37.5%
+
+3、靠背电机:30°
+
+4、腿托电机:22°
+
+
+## 空调
+1. 双区独立恒温锁定**23.5℃**,主驾温度不受干扰;
+
+2. 副驾出风口自动上调水平上吹,关闭直吹头部、腿部风道,杜绝冷风刺激;【部分支持:已用 passenger face 近似表达上吹;水平上吹、关闭头部/腿部风道和防直吹策略暂不支持。】
+
+3. 风量强制最低静音 1 档,自动切换内循环,开启 PM2.5 高压净化;【部分支持:风量 1 档和内循环已落库;PM2.5 高压净化暂不支持。】
+
+# 小腿解压悬浮模式
+## 座椅
+1、水平电机:105
+
+2、坐盆电机:75%
+
+3、靠背电机:0
+
+4、腿托电机:72°