优化后端和前端

This commit is contained in:
minghanchen
2026-08-24 14:15:53 +08:00
parent 8d05815773
commit 33c92d1352
27 changed files with 2328 additions and 234 deletions
+139 -28
View File
@@ -7023,14 +7023,14 @@ function renderExecution(result) {
const operationResults = report.spec.results.filter((item) => item.ref !== "seat-controller-firmware");
const succeededOperations = operationResults.filter((item) => item.status === "succeeded");
const failedOperations = operationResults.filter((item) => item.status === "failed");
const mqttPublishedOperations = operationResults.filter(
(item) => item.telemetry?.delivery_status === "published",
const mqttSubmittedOperations = operationResults.filter(
(item) => ["queued", "published"].includes(item.telemetry?.delivery_status),
);
const physicallyConfirmedOperations = operationResults.filter(
(item) => item.telemetry?.physical_effect_confirmed === true,
);
const mqttPublishedUnconfirmed = result.control_transport === "wireless_mqtt"
&& mqttPublishedOperations.length > physicallyConfirmedOperations.length;
&& mqttSubmittedOperations.length > physicallyConfirmedOperations.length;
const succeededDomains = [...new Set(succeededOperations.map((item) => item.domain))];
const failedDomains = [...new Set(failedOperations.map((item) => item.domain))];
let executionDetail = modeSave
@@ -7041,7 +7041,7 @@ function renderExecution(result) {
? `${executedDomains.join(" / ")} 已完成隔离 HIL 仿真,未访问物理 CAN`
: physical
? result.control_transport === "wireless_mqtt"
? `${executedDomains.join(" / ")} 已通过 MQTT 发布 ${mqttPublishedOperations.length || succeededOperations.length} 个白名单控制步骤${mqttPublishedUnconfirmed ? ";车辆效果尚未确认,以状态话题回读为准" : `,并确认 ${physicallyConfirmedOperations.length || succeededOperations.length} 个车辆状态`}`
? `${executedDomains.join(" / ")} 已向 MQTT 提交 ${mqttSubmittedOperations.length || succeededOperations.length} 个白名单控制步骤${mqttPublishedUnconfirmed ? ";车辆效果尚未确认,以状态话题回读为准" : `,并确认 ${physicallyConfirmedOperations.length || succeededOperations.length} 个车辆状态`}`
: selectedVehicleTransport()?.target_policy === "ch1_body_can_driver_only"
? `${executedDomains.join(" / ")} 已通过 CH1 下发,并以车端状态字段确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤`
: `${executedDomains.join(" / ")} 已下发真实 CAN,并以 IAR 固件实际反馈确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤`
@@ -7081,7 +7081,7 @@ function renderExecution(result) {
: presetSave ? "姿态预设已保存"
: simulatedHil ? "模拟台架执行成功"
: physical
? mqttPublishedUnconfirmed ? "MQTT 指令已发布" : "真实台架执行成功"
? mqttPublishedUnconfirmed ? "MQTT 指令已提交" : "真实台架执行成功"
: "虚拟执行成功"
: partiallySucceeded ? (physical ? "真实台架部分执行成功" : "部分执行成功")
: simulatedHil ? "模拟故障已触发" : physical ? "真实台架执行失败" : "虚拟执行失败";
@@ -7091,7 +7091,7 @@ function renderExecution(result) {
summary.innerHTML = `<i data-lucide="${executionIcon}"></i><div><strong>${executionTitle}</strong><span>${executionDetail}</span></div>`;
$("#resultNavMeta").textContent = reportSucceeded
? mqttPublishedUnconfirmed
? `${mqttPublishedOperations.length} 项已发布 · MQTT`
? `${mqttSubmittedOperations.length} 项已提交 · MQTT`
: `${report.spec.results.length} 项完成 · ${simulatedHil ? "SIM-HIL" : physical ? "HIL" : "DRY-RUN"}`
: partiallySucceeded ? `${succeededOperations.length} 项成功 / ${failedOperations.length} 项失败 · 查看明细`
: "执行失败 · 查看回读";
@@ -7251,7 +7251,7 @@ function renderResult(result) {
const feedbackSource = result.execution_report?.metadata?.labels?.seat_feedback;
const mqttPublishedUnconfirmed = result.control_transport === "wireless_mqtt"
&& (result.execution_report?.spec?.results || []).some(
(item) => item.telemetry?.delivery_status === "published"
(item) => ["queued", "published"].includes(item.telemetry?.delivery_status)
&& item.telemetry?.physical_effect_confirmed !== true,
);
if (feedbackSource === "simulated_hardware") {
@@ -7291,7 +7291,7 @@ function renderResult(result) {
: result.scene_config
? `${domains.length} 域场景闭环完成`
: mqttPublishedUnconfirmed
? `${domainLabels[domains[0]] || "车舱"}控制指令已发布`
? `${domainLabels[domains[0]] || "车舱"}控制指令已提交`
: `${domainLabels[domains[0]] || "车舱"}单域闭环完成`
: "请求未执行";
const conversationLabel = result.conversation_id?.slice(-8) || "no-context";
@@ -7324,6 +7324,67 @@ function renderResult(result) {
window.dispatchEvent(new CustomEvent("cognitive-agent:result", { detail: result }));
}
async function postControlPhase(path, body, executionMode, onEvent = null) {
const streaming = typeof onEvent === "function";
const response = await apiRequest(path, {
method: "POST",
timeoutMs: requestTimeoutForExecutionMode(executionMode),
headers: {
"Content-Type": "application/json",
...(streaming ? { Accept: "application/x-ndjson" } : {}),
},
body: JSON.stringify(body),
});
if (response.status === 401) {
window.location.replace("/login");
return null;
}
if (streaming && response.ok && response.body?.getReader) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffered = "";
let resultPayload = null;
const handleLine = (line) => {
if (!line.trim()) return;
const phaseEvent = JSON.parse(line);
if (phaseEvent.type === "error") {
const requestError = new Error(
phaseEvent.message || phaseEvent.error || "流式控制请求失败",
);
requestError.code = phaseEvent.error;
requestError.retryAfter = Number(phaseEvent.retry_after_seconds || 0);
throw requestError;
}
if (phaseEvent.type === "result") {
resultPayload = phaseEvent.payload;
} else {
onEvent(phaseEvent);
}
};
while (true) {
const { value, done } = await reader.read();
buffered += decoder.decode(value || new Uint8Array(), { stream: !done });
const lines = buffered.split("\n");
buffered = lines.pop() || "";
lines.forEach(handleLine);
if (done) break;
}
if (buffered.trim()) handleLine(buffered);
if (!resultPayload) throw new Error("流式控制请求未返回完整结果");
return resultPayload;
}
const payload = await response.json();
if (!response.ok) {
const requestError = new Error(payload.message || payload.error || `HTTP ${response.status}`);
requestError.code = payload.error;
requestError.retryAfter = Number(
payload.retry_after_seconds || response.headers.get("Retry-After") || 0,
);
throw requestError;
}
return payload;
}
async function submitRequest(event) {
event.preventDefault();
const utterance = $("#utterance").value.trim();
@@ -7360,11 +7421,7 @@ async function submitRequest(event) {
setRunStatus("processing", "PROCESSING");
$("#resultTitle").textContent = "正在执行车舱编排";
try {
const response = await apiRequest("/api/requests", {
method: "POST",
timeoutMs: requestTimeoutForExecutionMode(state.executionMode),
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
const requestBody = {
utterance,
client_type: "web_console",
provider: state.provider,
@@ -7379,20 +7436,74 @@ async function submitRequest(event) {
...(state.executionMode === "simulated_hardware"
? { simulation_fault: $("#simulationFault").value }
: {}),
}),
});
const payload = await response.json();
if (response.status === 401) {
window.location.replace("/login");
return;
}
if (!response.ok) {
const requestError = new Error(payload.message || payload.error || `HTTP ${response.status}`);
requestError.code = payload.error;
requestError.retryAfter = Number(
payload.retry_after_seconds || response.headers.get("Retry-After") || 0,
};
let payload;
let supplementWarning = "";
const onControlPhaseEvent = (phaseEvent) => {
if (phaseEvent.type !== "validated_commands" || !phaseEvent.payload) return;
window.dispatchEvent(new CustomEvent(
"cognitive-agent:validated-can-commands",
{ detail: phaseEvent.payload },
));
};
if (state.provider === "openai") {
const preview = await postControlPhase(
"/api/requests/preview",
requestBody,
state.executionMode,
onControlPhaseEvent,
);
throw requestError;
if (!preview) return;
renderResult(preview);
completedResult = preview;
if (preview.supplement_required) {
setRunStatus("processing", "CLOUD SUPPLEMENT");
$("#resultTitle").textContent = "本地指令已处理,正在补充云端解析";
try {
payload = await postControlPhase(
`/api/requests/${preview.request_id}/supplement`,
{
client_type: "web_console",
provider: state.provider,
reasoning_effort: state.reasoningEffort,
...(state.executionMode === "hardware" && !driverOnlyCh1
? { hardware_client_id: state.hardwareClientId }
: {}),
},
state.executionMode,
onControlPhaseEvent,
);
if (!payload) return;
} catch (error) {
const localCommandCount = Object.values(preview.function_configs || {})
.reduce((total, config) => total + (config?.spec?.operations?.length || 0), 0);
if (!localCommandCount) throw error;
payload = {
...preview,
status: "partially_succeeded",
supplement_required: false,
cloud_supplement_error: {
code: error.code || "cloud_supplement_failed",
message: error.message,
},
decision_summary: [
...(preview.decision_summary || []),
`云端补充失败:${error.message};本地规则命令已保留且不会撤销。`,
],
};
supplementWarning = "本地规则命令已执行;云端补充失败,已保留本地结果";
}
} else {
payload = preview;
}
} else {
payload = await postControlPhase(
"/api/requests",
requestBody,
state.executionMode,
onControlPhaseEvent,
);
if (!payload) return;
}
renderResult(payload);
completedResult = payload;
@@ -7405,7 +7516,7 @@ async function submitRequest(event) {
setModeExecutionState(submittedModeProfileId, completed ? "succeeded" : "failed");
}
const simulated = payload.execution_report?.metadata?.labels?.seat_feedback === "simulated_hardware";
showToast(completed
showToast(supplementWarning || (completed
? (payload.mode_profile
? `${payload.mode_profile.name}已创建并添加到功能模式`
: payload.status === "partially_succeeded"
@@ -7415,7 +7526,7 @@ async function submitRequest(event) {
: payload.execution_report?.spec?.mode === "hil"
? "真实台架执行与反馈确认已完成"
: "可执行动作的 dry-run 已完成")
: simulated ? "模拟故障已触发,详情见执行结果" : "请求未执行");
: simulated ? "模拟故障已触发,详情见执行结果" : "请求未执行"));
} catch (error) {
completedError = error;
if (submittedModeProfileId) setModeExecutionState(submittedModeProfileId, "failed");
+18
View File
@@ -7201,6 +7201,24 @@ body.device-power-drawer-open {
text-align: center;
}
.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-source {
flex: 0 0 auto;
padding: 1px 5px;
color: #2366a1;
background: #e4f0fb;
border: 1px solid rgba(35, 102, 161, 0.14);
border-radius: 999px;
font-size: 7px;
font-weight: 850;
line-height: 1.5;
}
.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-source[data-parse-source="local_rule"] {
color: #08766f;
background: #d9f3ef;
border-color: rgba(8, 118, 111, 0.14);
}
.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li[data-can-channel="CAN0"] {
background: #f7f4ec;
}
+33 -4
View File
@@ -21,6 +21,7 @@
};
let reasoningPickerControl = null;
let validatedCommandsVisible = false;
function make(tag, className, html) {
const node = document.createElement(tag);
@@ -61,8 +62,13 @@
const target = normalizedCanTarget(operation.target);
const onOff = normalizedOnOff(parameters.state ?? parameters.power);
const hyphenated = (value) => String(value || "").replaceAll("_", "-");
const parseSource = String(operation.parse_source || "");
const command = (category, text, channel = "CAN1", frame = "") => ({
channel, category, command: text, ...(frame ? { frame } : {}),
channel,
category,
command: text,
...(frame ? { frame } : {}),
...(parseSource ? { parseSource } : {}),
});
const hexByte = (value) => Number(value).toString(16).padStart(2, "0").toUpperCase();
const otaCan0Frame = (opcode, selector, argument) => {
@@ -271,7 +277,15 @@
if (item) commands.push(item);
});
});
return [...new Map(commands.map((item) => [`${item.channel}:${item.command}`, item])).values()];
const unique = new Map();
commands.forEach((item) => {
const key = `${item.channel}:${item.command}`;
const previous = unique.get(key);
if (!previous || (previous.parseSource !== "local_rule" && item.parseSource === "local_rule")) {
unique.set(key, item);
}
});
return [...unique.values()];
}
function renderParsedCanCommands(result, { message = "" } = {}) {
@@ -285,7 +299,11 @@
if (commands.length) {
list.innerHTML = `<ol>${commands.map((item) => `
<li data-can-channel="${escapeHtml(item.channel)}">
<span><b class="v2-can-command-channel">${escapeHtml(item.channel)}</b>${escapeHtml(item.category)}</span>
<span>
<b class="v2-can-command-channel">${escapeHtml(item.channel)}</b>
${item.parseSource ? `<b class="v2-can-command-source" data-parse-source="${escapeHtml(item.parseSource)}">${item.parseSource === "local_rule" ? "本地规则" : "云端模型"}</b>` : ""}
${escapeHtml(item.category)}
</span>
<code>${escapeHtml(item.command)}</code>
${item.frame ? `<small class="v2-can-command-frame">seat-ota · ${escapeHtml(item.frame)}</small>` : ""}
</li>`).join("")}</ol>`;
@@ -1348,10 +1366,19 @@
});
$("#v2LogFilter")?.addEventListener("change", renderSyncLogs);
$("#requestForm")?.addEventListener("submit", () => {
validatedCommandsVisible = false;
renderParsedCanCommands(null, { message: "正在解析自然语言并生成 CAN 控制指令…" });
});
window.addEventListener("cognitive-agent:validated-can-commands", (event) => {
const result = event.detail || {};
validatedCommandsVisible = parsedCanCommands(result).length > 0;
renderParsedCanCommands(result, {
message: "已完成白名单校验,正在下发控制指令…",
});
});
window.addEventListener("cognitive-agent:result", (event) => {
const result = event.detail || {};
validatedCommandsVisible = parsedCanCommands(result).length > 0;
renderParsedCanCommands(result);
appendSyncLog({
key: result.request_id ? `result:${result.request_id}` : "",
@@ -1375,8 +1402,10 @@
});
renderSyncedDevices();
}
if (event.detail?.error) {
if (event.detail?.error && !validatedCommandsVisible) {
renderParsedCanCommands(null, { message: "本次请求未生成 CAN 控制指令" });
}
if (event.detail?.error) {
appendSyncLog({ category: "task", title: "编排任务执行失败", detail: event.detail.error.message || "请求失败", status: "error" });
}
});
+3 -3
View File
@@ -10,17 +10,17 @@
<link rel="icon" href="/icons/app-icon-192.png" />
<title>511工程-智能座舱</title>
<link rel="stylesheet" href="/styles.css?v=dual-seat-16" />
<link rel="stylesheet" href="/console-v2.css?v=sync-layout-40" />
<link rel="stylesheet" href="/console-v2.css?v=sync-layout-41" />
<link rel="stylesheet" href="/self-extension.css?v=sync-layout-26" />
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
<script src="/vendor/three/0.147.0/three.min.js" defer></script>
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
<script src="/network.js?v=android-pad-2" defer></script>
<script src="/app.js?v=dual-seat-101" defer></script>
<script src="/app.js?v=dual-seat-103" defer></script>
<script type="module" src="/gesture-control.js?v=android-pad-9"></script>
<script src="/pwa.js?v=android-pad-3" defer></script>
<script src="/console-v2.js?v=sync-layout-41" defer></script>
<script src="/console-v2.js?v=sync-layout-43" defer></script>
<script src="/self-extension.js?v=sync-layout-26" defer></script>
</head>
<body class="console-v2">
+4 -4
View File
@@ -1,4 +1,4 @@
const CACHE_VERSION = "cognitive-agent-pad-v202";
const CACHE_VERSION = "cognitive-agent-pad-v204";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [
@@ -6,11 +6,11 @@ const APP_SHELL = [
"/index.html",
"/styles.css?v=dual-seat-16",
"/network.js?v=android-pad-2",
"/app.js?v=dual-seat-101",
"/app.js?v=dual-seat-103",
"/gesture-control.js?v=android-pad-9",
"/pwa.js?v=android-pad-3",
"/console-v2.css?v=sync-layout-40",
"/console-v2.js?v=sync-layout-41",
"/console-v2.css?v=sync-layout-41",
"/console-v2.js?v=sync-layout-43",
"/self-extension.css?v=sync-layout-26",
"/self-extension.js?v=sync-layout-26",
"/assets/pets/self-extension-cat.png?v=3",
@@ -12,6 +12,10 @@
"capability": {"type": "string", "pattern": "^[a-z][a-z0-9_]*\\.[a-z][a-z0-9_]*$"},
"target": {"$ref": "common.schema.json#/$defs/target"},
"parameters": {"type": "object"},
"parse_source": {
"type": "string",
"enum": ["local_rule", "cloud_model", "cloud_supplement"]
},
"motion": {
"type": "object",
"additionalProperties": false,
+74
View File
@@ -0,0 +1,74 @@
# CAN1 控制指令白名单
- 适用项目:`Cognitive_Agent`
- 适用分支:`feature/N7Web`
- 核对日期:2026-08-24
- 验证层级:自然语言解析、精确指令解析和 CAN1 白名单编译通过;未在本次核对中发送真实 CAN 帧。
“发送逻辑”列以当前执行器代码为准。有线模式按该顺序直接提交 CAN1 帧;无线 MQTT 模式将每个原始 CAN 帧分别封装为 1 条 `n7/n7-headunit/can/command` 消息(默认 QoS 1)。因此表中“3 帧,间隔 100 ms”在无线模式下表示后端连续发布 3 条 MQTT 消息,每条消息内部仍为 `repeat: 1`、`intervalMs: 0`。MQTT 客户端或 Broker 接受消息只代表已提交,车辆是否完成动作仍以状态话题的反馈帧为准。
## 有效白名单
| 类别 | 有效指令 | 参数约束 | 发送逻辑 |
| --- | --- | --- | --- |
| 后排门锁 | `door rear <lock\|unlock>` | 仅后排门锁 | `0x32B` 经典 CAN,发送 1 帧;反馈 ID `0x407` |
| 车窗动作 | `window <all\|front\|rear\|front-left\|front-right\|rear-left\|rear-right> <open\|close\|stop>` | 目标和动作必须使用列出的枚举值 | `0x431` CAN FD+BRS,连续 3 帧,相邻帧间隔 100 ms;反馈 ID `0x388` |
| 单窗位置 | `window <front-left\|front-right\|rear-left\|rear-right> position <0-100>` | 仅支持单个车窗,位置为整数百分比 | 先等待最长 2500 ms 的有效 `0x388`;已在目标 ±3% 内则发送 0 帧。否则用 `0x431` CAN FD+BRS 发送 3 帧启动命令(间隔 100 ms),最长闭环监听 `0x388` 12 s;无论到位或超时,最后强制发送 3 帧停止命令(间隔 100 ms) |
| 空调电源 | `ac <on\|off>` | 整车 HVAC 电源 | `0x1DE` 经典 CAN,连续 3 帧,间隔 100 ms;反馈 ID `0x404` |
| 空调风量 | `ac fan <1-7>` | 只接受 1–7 的整数 | `0x1DE` 经典 CAN,连续 3 帧,间隔 100 ms;反馈 ID `0x404` |
| 出风模式 | `ac mode <face\|face-feet\|feet\|feet-defrost\|defrost\|face-feet-defrost\|face-defrost>` | 仅接受列出的 7 种模式 | `0x1DE` 经典 CAN,连续 3 帧,间隔 100 ms;反馈 ID `0x404` |
| 空调温度 | `ac temp <driver\|passenger\|rear> <18-32>` | 温度只接受 18–32 的整数 | `0x1DE` 经典 CAN;`driver`/`passenger` 各发 3 帧(间隔 100 ms),`rear` 发 1 帧;反馈 ID `0x404` |
| 空调控制模式 | `ac control <manual\|auto>` | 手动或自动 | `0x1DE` 经典 CAN,连续 3 帧,间隔 100 ms;反馈 ID `0x404` |
| 空调循环 | `ac cycle <internal\|external>` | 内循环或外循环 | `0x1DE` 经典 CAN,连续 3 帧,间隔 100 ms;反馈 ID `0x404` |
| 风口百分比 | `outlet <driver-left\|driver-right\|passenger-left\|passenger-right> percent <0-100> <0-100>` | 依次为左右、上下整数百分比 | `0x4EF` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x2F5`/`0x37B` |
| 风口标定坐标 | `outlet <区域> position <左右坐标> <上下坐标>` | 必须同时满足通用有符号范围和对应风口标定范围 | `0x4EF` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x2F5`/`0x37B` |
| 主驾座椅通风/加热 | `seat driver <vent\|heat> <off\|low\|high>` | `front-left` 可作为主驾输入兼容别名,规范输出仍为 `driver` | `0x431` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` |
| 主驾座椅按摩关闭 | `seat driver massage off` | 仅关闭动作 | `0x4C1` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` |
| 主驾座椅按摩强度 | `seat driver massage strength <weak\|medium\|strong>` | `1\|2\|3` 也可作为输入兼容别名,规范输出为单词枚举 | `0x4C1` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` |
| 主驾座椅按摩模式 | `seat driver massage mode <wave\|pulse\|stretch\|slack\|relax>` | 仅接受列出的 5 种模式 | `0x4C1` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` |
| 主驾座椅水平调节 | `seat driver horizontal <forward\|backward> [duration-ms]` | `duration-ms` 为 100–2000;省略时默认 500 ms | `0x4C1` CAN FD+BRS;动作帧数为 `ceil(duration-ms/100)`,间隔 100 ms,最后一帧后额外保持 100 ms;随后强制发送 3 帧 STOP(间隔 100 ms);反馈 ID `0x494` |
| 主驾座椅高度调节 | `seat driver height <up\|down> [duration-ms]` | `duration-ms` 为 100–2000;省略时默认 500 ms | `0x4C1` CAN FD+BRS;动作帧数为 `ceil(duration-ms/100)`,间隔 100 ms,最后一帧后额外保持 100 ms;随后强制发送 3 帧 STOP(间隔 100 ms);反馈 ID `0x494` |
| 主驾座椅靠背调节 | `seat driver backrest <forward\|backward> [duration-ms]` | `duration-ms` 为 100–2000;省略时默认 500 ms | `0x4C1` CAN FD+BRS;动作帧数为 `ceil(duration-ms/100)`,间隔 100 ms,最后一帧后额外保持 100 ms;随后强制发送 3 帧 STOP(间隔 100 ms);反馈 ID `0x494` |
| 主驾座椅坐盆调节 | `seat driver basin <up\|down> [duration-ms]` | `duration-ms` 为 100–2000;省略时默认 500 ms | `0x4C1` CAN FD+BRS;动作帧数为 `ceil(duration-ms/100)`,间隔 100 ms,最后一帧后额外保持 100 ms;随后强制发送 3 帧 STOP(间隔 100 ms);反馈 ID `0x494` |
| 主驾座椅停止 | `seat driver <horizontal\|height\|backrest\|basin> stop` | `stop` 后不允许附加持续时间 | `0x4C1` CAN FD+BRS,直接连续发送 3 帧 STOP,间隔 100 ms;反馈 ID `0x494` |
| 主驾座椅记忆 | `seat driver position <1-3>` | 需要先由操作员完成 PAD 只读学习 | `0x4C1` CAN FD+BRS;下发前最长等待 2500 ms 的新鲜 `0x494`,且四轴初始化均为真;然后将已学习的 `0x16 → 0x15` 交替序列发送 10 轮,共 20 帧,间隔 50 ms;发送后最长等待 1200 ms 的 `0x494` |
| 主驾座椅记忆兼容别名 | `seat driver memory recall <1-3>` | 与 `seat driver position <1-3>` 使用同一已学习帧序列 | 发送逻辑与 `seat driver position <1-3>` 完全相同:`0x4C1` CAN FD+BRS,四轴初始化门禁通过后发送 20 帧 `0x16/0x15` 交替序列,间隔 50 ms |
| 室内灯总开关 | `light indoor <on\|off>` | 整车室内灯总开关 | `0x4F5` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x42E` |
| 全部阅读灯 | `light reading all <on\|off>` | CAN1 仅验证 `all` 目标 | `0x4F5` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x42E` |
| 氛围灯开关 | `light ambient <on\|off>` | 氛围灯总开关 | `0x3FC` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x42E` |
| 氛围灯颜色 | `light ambient color <1-127>` | 车型颜色索引,只接受整数 | `0x3FC` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x42E` |
| 氛围灯亮度 | `light ambient brightness <1-5>` | 只接受 1–5 的整数档位 | `0x3FC` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x42E` |
| 遮阳帘动作 | `shade <open\|close\|half\|stop>` | 0/50/100 位置与已验证预设动作复用 | `0x431` 经典 CAN;`open/close/half` 先发 1 帧预设命令,最长监听 `0x41A` 15 s,在匹配目标位置但仍在运动时追加 1 帧 STOP 并确认停止;`stop` 发 1 帧 STOP 并等待停止反馈 |
| 遮阳帘位置 | `shade position <0-100>` | 中间位置使用状态反馈闭环并自动停止 | 0/50/100 按上述预设逻辑发送。其他位置先等待最长 2500 ms 的新鲜 `0x41A`;已在目标 ±2% 内且停止则发 0 帧,已到位但仍运动则发 1 帧 STOP。否则用 `0x431` 经典 CAN 发 1 帧开/关启动命令,最长监听 15 s,并在 `finally` 中强制发 1 帧 STOP,再确认位置与停止状态 |
## 风口标定坐标范围
| 风口 | 左右范围 | 上下范围 |
| --- | ---: | ---: |
| `driver-left` | -25–40 | -40–30 |
| `driver-right` | -40–30 | -40–30 |
| `passenger-left` | -10–40 | -40–30 |
| `passenger-right` | -40–5 | -40–30 |
风口原始坐标语法层的通用范围为 -128–126,但只有同时落在上表对应风口实际标定范围内才能通过编译。
## 能力层扩展与非白名单入口
| 指令/能力 | 当前状态 | 发送逻辑 |
| --- | --- | --- |
| `ac compressor <on\|off>` | `climate.set_ac` 在后端能力层可编译,但当前没有对应的直接精确文本解析入口 | 若由受信能力层调用:`0x1DE` 经典 CAN,3 帧,间隔 100 ms,反馈 ID `0x404`;但本项不是直接指令白名单入口 |
| `door vehicle <lock\|unlock>` | 可解析,但当前 CH1 实车验证未观察到有效整车门锁动作,因此会被安全拒绝,不属于可下发白名单 | 安全门禁拒绝,发送 0 帧 |
| `ac defrost <on\|off>` | 存在旧解析入口,但当前 CAN1 运行时未实现;前除霜应使用 `ac mode defrost` | 编译门禁拒绝,发送 0 帧 |
## CAN0 边界
本文档只描述 CAN1 车身控制白名单。CAN0 只用于副驾座椅,其状态、轴位置、热舒适和动态模式控制必须依据 `seat-ota` 协议独立处理,不得使用本 CAN1 白名单生成或猜测 CAN0 帧。
## 实现与回归测试
- 精确命令和自然语言匹配:`src/cognitive_agent/application/native_can_intent.py`
- 能力、参数及 CAN 帧编译门禁:`src/cognitive_agent/application/native_can_runtime.py`
- 风口标定范围:`platforms/t2g_cabin_bench/calibration/body_can.yaml`
- 完整白名单回归测试:`tests/unit/test_can1_semantic_whitelist.py`
白名单回归测试展开覆盖 119 条有效命令,并验证越界、非整数、非法枚举和超出风口标定范围的指令不能通过白名单门禁。
+1 -1
View File
@@ -22,7 +22,7 @@ spec:
- name: climate.set_temperature
description: Set an absolute HVAC temperature from 18 to 32 degrees Celsius.
target_devices: [hvac]
target_scopes: [driver, passenger, front_row, rear_row, rear_left, rear_right, vehicle]
target_scopes: [driver, passenger, front_row, rear, rear_row, rear_left, rear_right, vehicle]
parameters:
- name: temperature_c
type: number
+12
View File
@@ -395,6 +395,9 @@ def build_function_config(
"capability": DYNAMIC_MODE_STOP_CAPABILITY,
"target": TARGETS[action["target"]],
"parameters": {},
"parse_source": str(
action.get("parse_source", "local_rule")
),
"timeout_ms": 5000,
"failure_policy": "abort",
}
@@ -412,6 +415,9 @@ def build_function_config(
"capability": DYNAMIC_MODE_CAPABILITY,
"target": TARGETS[action["target"]],
"parameters": {"mode_id": int(action["mode_id"])},
"parse_source": str(
action.get("parse_source", "local_rule")
),
"timeout_ms": 5000,
"failure_policy": "abort",
}
@@ -437,6 +443,9 @@ def build_function_config(
"capability": MOTION_CAPABILITIES[action["axis"]],
"target": TARGETS[action["target"]],
"parameters": parameters,
"parse_source": str(
action.get("parse_source", "local_rule")
),
"timeout_ms": 8000,
"failure_policy": "abort",
}
@@ -460,6 +469,9 @@ def build_function_config(
"capability": CAPABILITIES[action["function"]],
"target": TARGETS[action["target"]],
"parameters": parameters,
"parse_source": str(
action.get("parse_source", "local_rule")
),
"timeout_ms": 3000,
"failure_policy": "abort",
}
+19
View File
@@ -52,6 +52,15 @@ STRONG_PATTERN = re.compile(r"很|太|非常|特别|难受")
AIRFLOW_CAUSE_PATTERN = re.compile(r"吹|风太大|风吹")
FIRST_PERSON_PATTERN = re.compile(r"我|自己")
VEHICLE_SCOPE_PATTERN = re.compile(r"车上|车内|车里|全车|整车|整个车")
NON_SEAT_DEVICE_PATTERN = re.compile(
r"车窗|车门|门锁|空调|温度|风量|风速|出风模式|"
r"内循环|外循环|循环模式|风口|遮阳帘|天幕|阅读灯|"
r"室内灯|车内灯|氛围灯"
)
SEAT_CONTROL_PATTERN = re.compile(
r"座椅|坐椅|座位|靠背|椅背|坐盆|坐垫|滑轨|腿托|脚托|四轴|"
r"通风|加热|按摩|记忆|预设"
)
PASSENGER_ONLY_QUESTION = "当前所有座椅能力仅控制副驾,不支持主驾座椅目标。"
UNSUPPORTED_SEAT_SCOPE_PATTERN = re.compile(
r"后排|第二排|二排|第三排|三排|左前座椅|左前座|左前|"
@@ -799,6 +808,16 @@ def parse_local_intent(
"""Parse common Chinese seat commands without making hardware assumptions."""
text = _normalize_text(utterance)
if NON_SEAT_DEVICE_PATTERN.search(text) and not SEAT_CONTROL_PATTERN.search(text):
# This parser owns CAN0 passenger-seat semantics only. Occupant words
# such as “主驾/副驾” in a window or climate clause are spatial
# qualifiers for that device and must not create a seat ambiguity.
return {
"actions": [],
"ambiguities": [],
"decision_summary": [],
"confidence": 0.0,
}
clauses = [part for part in re.split(r"[,,。;;!!??]", text) if part]
actions: list[dict[str, Any]] = []
motion_actions: list[dict[str, Any]] = []
@@ -311,7 +311,7 @@ class SeatServiceIntegrationTests(unittest.TestCase):
]
self.assertEqual(
commands,
["seat driver memory recall 3", "ac temp driver 25", "ac fan 1"],
["seat driver position 3", "ac temp driver 25", "ac fan 1"],
)
def test_model_climate_request_completes_domain_loop(self) -> None:
+15
View File
@@ -67,6 +67,21 @@ class LocalIntentParserTests(unittest.TestCase):
self.assertEqual(result["actions"][0]["target"], "passenger")
self.assertEqual(result["ambiguities"], [])
def test_non_seat_spatial_qualifiers_do_not_enter_can0_seat_rules(self) -> None:
utterances = (
"打开主驾车窗",
"打开副驾车窗",
"把主驾和副驾温度都设为24度",
"风量调到2档",
"设置主驾出风模式为吹脸",
)
for utterance in utterances:
with self.subTest(utterance=utterance):
result = parse_local_intent(utterance)
self.assertEqual(result["actions"], [])
self.assertEqual(result["ambiguities"], [])
def test_parses_four_axis_zero_for_passenger(self) -> None:
result = parse_local_intent("把水平、坐盆、靠背、腿托四轴归零")
@@ -567,14 +567,19 @@ class ConversationStateStore:
device = state["domain_devices"][domain]
if domain == "climate":
# The trusted CAN command vocabulary uses the concise token
# ``rear`` while the conversation state stores that same HVAC
# zone as ``rear_row``. Keep the public command stable and
# normalize only at the state boundary.
zone_target = "rear_row" if target == "rear" else target
if capability == "climate.set_power":
device["zones"][target]["power"] = parameters["state"]
device["zones"][zone_target]["power"] = parameters["state"]
if target == "vehicle":
device["power"] = parameters["state"]
for zone in device["zones"].values():
zone["power"] = parameters["state"]
elif capability == "climate.set_temperature":
device["zones"][target]["temperature_c"] = parameters[
device["zones"][zone_target]["temperature_c"] = parameters[
"temperature_c"
]
if target == "vehicle":
@@ -589,7 +594,7 @@ class ConversationStateStore:
elif capability == "climate.set_front_defrost":
device["front_defrost"] = parameters["state"]
elif capability == "climate.set_outlet_mode":
device["zones"][target]["outlet_mode"] = parameters["mode"]
device["zones"][zone_target]["outlet_mode"] = parameters["mode"]
elif capability == "climate.set_control_mode":
device["control_mode"] = parameters["mode"]
device["feedback_source"] = feedback_source
@@ -78,6 +78,9 @@ class ConfiguredDomainRuntime:
"zone": action["target"],
},
"parameters": dict(action["parameters"]),
"parse_source": str(
action.get("parse_source", "local_rule")
),
"timeout_ms": 8000 if self.domain == "roof" else 4000,
"failure_policy": "abort",
}
@@ -25,6 +25,16 @@ WINDOW_TARGETS = {
"后左": "rear_left",
"右后": "rear_right",
"后右": "rear_right",
# Natural cabin language names the front windows by occupant more often
# than by left/right. These aliases belong only to the window domain:
# driver = left-front, passenger = right-front.
"主驾驶": "front_left",
"驾驶位": "front_left",
"司机位": "front_left",
"主驾": "front_left",
"副驾驶": "front_right",
"乘客位": "front_right",
"副驾": "front_right",
}
WINDOW_GROUP_MEMBERS = {
"all": ["front_left", "front_right", "rear_left", "rear_right"],
@@ -49,6 +59,7 @@ LIGHT_TARGETS = {
"左后": "rear_left",
"右后": "rear_right",
}
CLAUSE_SEPARATOR = re.compile(r"[,,;;。!?!?\n]+")
# Defaults are intentionally scoped to the driver console and verified CH1
# body-CAN vocabulary. A missing target must never become the legacy
@@ -83,7 +94,8 @@ CH1_DEFAULT_TARGETS = {
_EXPLICIT_TARGET_PATTERNS = {
"window": re.compile(
r"(?:全部|所有|四个|全车|前排|后排|左前|前左|右前|前右|左后|后左|右后|后右)"
r"(?:全部|所有|四个|全车|前排|后排|左前|前左|右前|前右|左后|后左|右后|后右|"
r"主驾驶|驾驶位|司机位|主驾|副驾驶|乘客位|副驾)"
r"(?:的)?(?:车窗|窗)|window\s+(?:all|front|rear|front-left|front-right|rear-left|rear-right)",
re.IGNORECASE,
),
@@ -113,6 +125,88 @@ _EXPLICIT_TARGET_PATTERNS = {
}
def _scoped_text(
compact: str,
subject_pattern: str,
continuation_pattern: str | None = None,
) -> str:
"""Keep action words inside clauses that name the controlled device."""
clauses = [clause for clause in CLAUSE_SEPARATOR.split(compact) if clause]
subject_indexes = {
index
for index, clause in enumerate(clauses)
if re.search(subject_pattern, clause, re.IGNORECASE)
}
selected_indexes = set(subject_indexes)
if continuation_pattern:
selected_indexes.update(
index + 1
for index in subject_indexes
if index + 1 < len(clauses)
and re.search(
continuation_pattern,
clauses[index + 1],
re.IGNORECASE,
)
)
return ",".join(
clause
for index, clause in enumerate(clauses)
if index in selected_indexes
)
def _window_targets(text: str) -> tuple[list[str], bool]:
"""Resolve target aliases only when they are attached to a window noun."""
explicit: list[str] = []
broad: str | None = None
target_labels = "|".join(
re.escape(label)
for label in sorted(WINDOW_TARGETS, key=len, reverse=True)
)
for label, target in WINDOW_TARGETS.items():
attached = bool(
re.search(
rf"{re.escape(label)}"
rf"(?:(?:和|与|及|、|以及)(?:{target_labels}))*"
rf"(?:的)?(?:两个|两侧)?(?:车窗|窗)",
text,
)
)
if not attached:
continue
if target in WINDOW_GROUP_MEMBERS:
broad = broad or target
elif target not in explicit:
explicit.append(target)
exclusions = {
target
for label, target in WINDOW_TARGETS.items()
if target not in WINDOW_GROUP_MEMBERS
and re.search(
rf"{re.escape(label)}(?:车窗)?(?:除外|除去|不包括|不含)",
text,
)
}
if broad and exclusions:
return (
[
target
for target in WINDOW_GROUP_MEMBERS[broad]
if target not in exclusions
],
True,
)
if explicit:
return ([target for target in explicit if target not in exclusions], True)
if broad:
return ([broad], True)
return (["front_left"], False)
def _capability_domain(capability: str) -> str:
return "interior_light" if capability.startswith("interior_light.") else capability.split(".", 1)[0]
@@ -156,6 +250,7 @@ def _action(
"operation": "set",
"reason": reason,
"execution_backend": "native_can",
"parse_source": "local_rule",
}
@@ -284,6 +379,8 @@ def _parse_exact_command(text: str) -> list[dict[str, Any]]:
}
if direction not in valid_directions[axis]:
return []
if direction == "stop" and duration_token is not None:
return []
duration = (
500
if direction == "stop"
@@ -381,17 +478,23 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
actions: list[dict[str, Any]] = []
memory = re.search(
r"(?:记忆(?:位置|位)?|记忆座椅(?:位置)?|座椅记忆(?:位置)?|位置记忆)"
r"(?:"
r"(?:记忆(?:位置|位)?|预设(?:位置)?|记忆座椅(?:位置)?|"
r"座椅记忆(?:位置)?|位置记忆)"
r"(?:切换到|切换为|调整到|调到|唤醒|调用|恢复到|恢复为|设为|设置为)?"
r"([1-3])(?:号|档|位)?",
r"(?P<after>[1-3一二三])(?:号|档|位)?"
r"|(?P<before>[1-3一二三])号?(?:记忆|预设)(?:位置|位)?"
r")",
compact,
)
if memory is not None:
slot_token = memory.group("after") or memory.group("before")
slot = {"一": 1, "二": 2, "三": 3}.get(slot_token, slot_token)
actions.append(_action(
"seat",
"seat.recall_driver_memory_native",
"seat.set_driver_position_native",
"driver",
{"slot": int(memory[1])},
{"slot": int(slot)},
(
"用户明确指定了 CH1 主驾座椅记忆位置。"
if explicit_driver
@@ -449,8 +552,10 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
strength = (
1
if re.search(r"弱|低|1档", compact)
else 2
if re.search(r"中等|中档|2档", compact)
else 3
if re.search(r"强|高|3档", compact)
if re.search(r"强(?!度)|高|3档", compact)
else 2
)
massage = {"action": "strength", "strength": strength}
@@ -549,103 +654,149 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
compact = re.sub(r"\s+", "", text)
actions: list[dict[str, Any]] = _parse_chinese_driver_seat_commands(compact)
seat_text = _scoped_text(
compact,
r"座椅|座位|靠背|椅背|坐盆|坐垫|腿托|脚托|按摩|记忆|预设|"
r"通风|加热|前移|后移|向前移动|向后移动",
)
actions: list[dict[str, Any]] = _parse_chinese_driver_seat_commands(
seat_text
)
climate_text = _scoped_text(
compact,
r"空调|温度|风量|风速|内循环|外循环|循环模式|吹面|吹脸|吹脚|除霜|(?:AC|A/C)",
)
if (
"空调" in compact
"空调" in climate_text
or re.search(r"(?:AC|A/C)", text, re.IGNORECASE)
or re.search(r"温度|风量|风速|内循环|外循环|循环模式", compact)
or re.search(
r"温度|风量|风速|内循环|外循环|循环模式|吹面|吹脸|吹脚|除霜",
climate_text,
)
):
if re.search(r"(?:打开|开启|启动)空调", compact):
if re.search(r"(?:打开|开启|启动)空调", climate_text):
actions.append(_action("climate", "climate.set_power", "vehicle", {"state": "on"}, "用户要求打开空调。"))
elif re.search(r"(?:关闭|关掉|关上)空调", compact):
elif re.search(r"(?:关闭|关掉|关上)空调", climate_text):
actions.append(_action("climate", "climate.set_power", "vehicle", {"state": "off"}, "用户要求关闭空调。"))
temperature = re.search(
r"(?:(主驾|副驾|后排)(?:空调)?(?:温度)?|空调(?:温度)?|温度)(?:调到|设为|设置为|到)?(1[89]|2\d|3[0-2])(?:℃|度)?",
compact,
r"(?:空调(?:温度)?|温度)(?:都)?"
r"(?:调整到|调整为|调到|调为|设为|设置为|调至|到|为)?"
r"(1[89]|2\d|3[0-2])(?:摄氏)?(?:℃|度)?(?![\d.])",
climate_text,
)
if temperature:
target = {"主驾": "driver", "副驾": "passenger", "后排": "rear"}.get(temperature[1], "driver")
if (
re.search(r"全车|整车|前排|主副驾", climate_text)
or ("主驾" in climate_text and "副驾" in climate_text)
):
temperature_targets = ("driver", "passenger")
elif re.search(r"副驾|副驾驶|乘客", climate_text):
temperature_targets = ("passenger",)
elif "后排" in climate_text:
temperature_targets = ("rear",)
else:
temperature_targets = ("driver",)
explicit_temperature_target = bool(
re.search(
r"主驾|副驾|副驾驶|乘客|后排|前排|全车|整车|主副驾",
climate_text,
)
)
reason = (
"用户指定了空调温区和温度。"
if temperature[1]
if explicit_temperature_target
else "用户未指定空调温区,按驾驶台默认选择主驾温区。"
)
actions.append(_action("climate", "climate.set_temperature", target, {"temperature_c": int(temperature[2])}, reason))
actions.extend(
_action(
"climate",
"climate.set_temperature",
target,
{"temperature_c": int(temperature[1])},
reason,
)
for target in temperature_targets
)
fan = re.search(r"(?:风量|风速)(?:调到|设为|设置为|到)?([1-7])(?:档)?", compact)
fan = re.search(
r"(?:风量|风速)(?:调整到|调整为|调到|调为|设为|设置为|到)?"
r"([1-7])(?:档|级)?(?![\d.])",
climate_text,
)
if fan:
actions.append(_action("climate", "climate.set_fan_level", "vehicle", {"level": int(fan[1])}, "用户指定了空调风量。"))
if re.search(r"自动(?:控制|模式)", compact):
if re.search(r"自动(?:控制|模式)", climate_text):
actions.append(_action("climate", "climate.set_control_mode", "vehicle", {"mode": "auto"}, "用户指定空调自动控制。"))
elif re.search(r"手动(?:控制|模式)", compact):
elif re.search(r"手动(?:控制|模式)", climate_text):
actions.append(_action("climate", "climate.set_control_mode", "vehicle", {"mode": "manual"}, "用户指定空调手动控制。"))
if re.search(r"内循环", compact):
if re.search(r"内循环", climate_text):
actions.append(_action("climate", "climate.set_cycle_mode", "vehicle", {"mode": "internal"}, "用户指定空调内循环。"))
elif re.search(r"外循环", compact):
elif re.search(r"外循环", climate_text):
actions.append(_action("climate", "climate.set_cycle_mode", "vehicle", {"mode": "external"}, "用户指定空调外循环。"))
outlet_modes = [
("吹面吹脚除霜", "face_feet_defrost"),
("吹面除霜", "face_defrost"),
("吹脚除霜", "feet_defrost"),
("吹面吹脚", "face_feet"),
("除霜", "defrost"),
("吹脚", "feet"),
("吹面", "face"),
]
for label, mode in outlet_modes:
if label in compact:
actions.append(_action("climate", "climate.set_outlet_mode", "vehicle", {"mode": mode}, "用户指定了空调出风模式。"))
break
has_face = bool(re.search(r"吹面|吹脸", climate_text))
has_feet = "吹脚" in climate_text
has_defrost = "除霜" in climate_text
outlet_mode = (
"face_feet_defrost"
if has_face and has_feet and has_defrost
else "face_feet"
if has_face and has_feet
else "face_defrost"
if has_face and has_defrost
else "feet_defrost"
if has_feet and has_defrost
else "defrost"
if has_defrost
else "face"
if has_face
else "feet"
if has_feet
else None
)
if outlet_mode:
actions.append(
_action(
"climate",
"climate.set_outlet_mode",
"vehicle",
{"mode": outlet_mode},
"用户指定了空调出风模式。",
)
)
if "车窗" in compact:
explicit_targets = []
for label, target_name in WINDOW_TARGETS.items():
if target_name in WINDOW_GROUP_MEMBERS or label not in compact:
continue
if target_name not in explicit_targets:
explicit_targets.append(target_name)
broad_target = next(
(
target_name
for label, target_name in WINDOW_TARGETS.items()
if target_name in WINDOW_GROUP_MEMBERS and label in compact
),
None,
)
exclusions = {
target_name
for label, target_name in WINDOW_TARGETS.items()
if target_name not in WINDOW_GROUP_MEMBERS
and re.search(
rf"{re.escape(label)}(?:车窗)?(?:除外|除去|不包括|不含)",
window_text = _scoped_text(
compact,
r"车窗",
r"^(?:主驾|主驾驶|驾驶位|司机位|副驾|副驾驶|乘客位|"
r"左前|前左|右前|前右|左后|后左|右后|后右)(?:车窗)?(?:除外|除去|不包括|不含)$",
)
}
if broad_target and exclusions:
targets = [
target_name
for target_name in WINDOW_GROUP_MEMBERS[broad_target]
if target_name not in exclusions
]
elif explicit_targets:
targets = [target_name for target_name in explicit_targets if target_name not in exclusions]
else:
targets = [broad_target or "front_left"]
if window_text:
targets, explicit_window_target = _window_targets(window_text)
position = re.search(
r"(?:位置|开度|调到|设为|设置为|开启到|打开到)(\d{1,3})%",
compact,
r"(?:位置|开度|调整到|调到|设为|设置为|开启到|打开到|开到)(\d{1,3})%",
window_text,
)
half_position = bool(
re.search(
r"(?:开启|打开|开到|开度)?(?:一半|半开)",
window_text,
)
)
half_position = bool(re.search(r"(?:开启|打开|开到|开度)?(?:一半|半开)", compact))
position_pct = int(position[1]) if position else 50 if half_position else None
if position_pct is not None and all(target not in WINDOW_GROUP_MEMBERS for target in targets) and position_pct <= 100:
if (
position_pct is not None
and all(target not in WINDOW_GROUP_MEMBERS for target in targets)
and position_pct <= 100
):
reason = (
"用户指定了单个车窗的绝对开度。"
if explicit_targets or broad_target
if explicit_window_target
else "用户未指定车窗目标,按驾驶台默认选择主驾左前窗。"
)
actions.extend(
@@ -658,15 +809,15 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
pass
else:
motion = (
"stop" if re.search(r"停止|停下|暂停", compact)
else "close" if re.search(r"关闭|关上|升起|关窗", compact)
else "open" if re.search(r"打开|开启|降下|开窗", compact)
"stop" if re.search(r"停止|停下|暂停", window_text)
else "close" if re.search(r"关闭|关上|升起|关窗", window_text)
else "open" if re.search(r"打开|开启|降下|开窗", window_text)
else None
)
if motion:
reason = (
"用户指定了车窗目标和动作。"
if explicit_targets or broad_target
if explicit_window_target
else "用户未指定车窗目标,按驾驶台默认选择主驾左前窗。"
)
actions.extend(
@@ -674,17 +825,18 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
for target in targets
)
door_text = _scoped_text(compact, r"门锁|车门|后门|锁车|解锁车辆")
rear_lock_request = bool(
re.search(
r"(?:后门|后排车门|后排门锁|二排车门|二排门锁)", compact
r"(?:后门|后排车门|后排门锁|二排车门|二排门锁)", door_text
)
)
if rear_lock_request:
state = (
"unlock"
if re.search(r"解锁|开锁", compact)
if re.search(r"解锁|开锁", door_text)
else "lock"
if re.search(r"上锁|落锁|锁上", compact)
if re.search(r"上锁|落锁|锁上", door_text)
else None
)
if state:
@@ -695,59 +847,123 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
r"整车(?:门锁|车门)|全车(?:门锁|车门)|(?:所有|全部)(?:车门|门锁)|"
r"(?:车门|门锁)(?:上锁|落锁|锁上|解锁|开锁)|"
r"(?:锁车|整车解锁|全车解锁|解锁车辆)",
compact,
door_text,
)
)
if vehicle_lock_request:
state = (
"unlock"
if re.search(r"解锁|开锁", compact)
if re.search(r"解锁|开锁", door_text)
else "lock"
if re.search(r"上锁|落锁|锁上|锁车", compact)
if re.search(r"上锁|落锁|锁上|锁车", door_text)
else None
)
if state:
actions.append(_action("door", "door.set_vehicle_lock", "vehicle", {"state": state}, "用户指定了整车门锁状态。"))
if re.search(r"遮阳帘|天幕帘", compact):
position = re.search(r"(?:位置|开度|调到|设为)(\d{1,3})%", compact)
shade_text = _scoped_text(compact, r"遮阳帘|天幕帘")
if shade_text:
position = re.search(
r"(?:位置|开度|调整到|调到|设为|设置为|开到|打开(?:到)?|开启(?:到)?)(\d{1,3})%",
shade_text,
)
if position and int(position[1]) <= 100:
actions.append(_action("roof", "roof.set_shade_position", "vehicle", {"position_pct": int(position[1])}, "用户指定了遮阳帘开度。"))
else:
motion = (
"stop" if re.search(r"停止|停下|暂停", compact)
else "half" if re.search(r"半开|一半", compact)
else "close" if re.search(r"关闭|关上", compact)
else "open" if re.search(r"打开|开启", compact)
"stop" if re.search(r"停止|停下|暂停", shade_text)
else "half" if re.search(r"半开|一半", shade_text)
else "close" if re.search(r"关闭|关上", shade_text)
else "open" if re.search(r"打开|开启", shade_text)
else None
)
if motion:
actions.append(_action("roof", "roof.set_shade_motion", "vehicle", {"motion": motion}, "用户指定了遮阳帘动作。"))
indoor_text = _scoped_text(compact, r"室内灯|车内灯")
for label, capability in (("室内灯", "interior_light.set_indoor_power"), ("车内灯", "interior_light.set_indoor_power")):
if label in compact:
state = "off" if re.search(r"关闭|关掉|关上", compact) else "on" if re.search(r"打开|开启", compact) else None
if label in indoor_text:
state = "off" if re.search(r"关闭|关掉|关上", indoor_text) else "on" if re.search(r"打开|开启", indoor_text) else None
if state:
actions.append(_action("interior_light", capability, "vehicle", {"state": state}, "用户指定了车内灯状态。"))
break
if "阅读灯" in compact:
target = next((value for label, value in LIGHT_TARGETS.items() if label in compact), "all")
state = "off" if re.search(r"关闭|关掉|关上", compact) else "on" if re.search(r"打开|开启", compact) else None
reading_text = _scoped_text(compact, r"阅读灯")
if reading_text:
target = next((value for label, value in LIGHT_TARGETS.items() if label in reading_text), "all")
state = "off" if re.search(r"关闭|关掉|关上", reading_text) else "on" if re.search(r"打开|开启", reading_text) else None
if state and target == "all":
actions.append(_action("interior_light", "interior_light.set_reading_power", target, {"state": state}, "用户指定了阅读灯区域和状态。"))
if "氛围灯" in compact:
color_match = re.search(r"颜色(?:索引|编号)?(\d{1,3})", compact)
brightness_match = re.search(r"亮度(?:档|级)?([1-5])", compact)
ambient_text = _scoped_text(
compact,
r"氛围灯",
r"^(?:亮度|强度)(?:调整|调节|调|设置|设)?",
)
if ambient_text:
state = (
"off"
if re.search(r"关闭|关掉|熄灭", ambient_text)
else "on"
if re.search(r"打开|开启|点亮", ambient_text)
else None
)
if state:
actions.append(
_action(
"ambient_light",
"ambient_light.set_power",
"vehicle",
{"state": state},
"用户指定了氛围灯总开关状态。",
)
)
color_match = re.search(
r"(?:颜色编号|颜色索引|颜色|色号)"
r"(?:调整到|调整为|调到|调为|设置为|设为|为)?"
r"(\d{1,3})(?![\d.])",
ambient_text,
)
brightness_match = re.search(
r"(?:亮度|强度)(?:调整到|调整为|调到|调为|设置为|设为|为)?"
r"([1-5])(?:档|级)?(?![\d.])",
ambient_text,
)
if color_match and 1 <= int(color_match[1]) <= 127:
actions.append(_action("ambient_light", "ambient_light.set_color_index", "vehicle", {"color_index": int(color_match[1])}, "用户指定了已验证的氛围灯颜色索引。"))
if brightness_match:
actions.append(_action("ambient_light", "ambient_light.set_brightness_level", "vehicle", {"brightness_level": int(brightness_match[1])}, "用户指定了已验证的氛围灯亮度档位。"))
outlet_target = next((value for label, value in OUTLET_TARGETS.items() if f"{label}风口" in compact), None)
outlet_position = re.search(r"(?:左右|水平)(\d{1,3})%?.*?(?:上下|垂直)(\d{1,3})%?", compact)
if "风口" in compact and outlet_position and all(int(value) <= 100 for value in outlet_position.groups()):
outlet_text = _scoped_text(compact, r"风口")
outlet_target = next((value for label, value in OUTLET_TARGETS.items() if f"{label}风口" in outlet_text), None)
calibrated_position = re.search(
r"(?:标定坐标|标定位置).*?(?:左右|水平)"
r"(-?\d{1,3})(?![\d.]).*?(?:上下|垂直)(-?\d{1,3})(?![\d.])",
outlet_text,
)
outlet_position = re.search(
r"(?:左右|水平)(\d{1,3})%?(?![\d.]).*?"
r"(?:上下|垂直)(\d{1,3})%?(?![\d.])",
outlet_text,
)
if (
outlet_target
and calibrated_position
and all(-128 <= int(value) <= 126 for value in calibrated_position.groups())
):
actions.append(
_action(
"outlet",
"outlet.set_calibrated_position",
outlet_target,
{
"horizontal_value": int(calibrated_position[1]),
"vertical_value": int(calibrated_position[2]),
},
"用户指定了已标定风口的双轴原始坐标。",
)
)
elif outlet_text and outlet_position and all(int(value) <= 100 for value in outlet_position.groups()):
target = outlet_target or "driver_left"
reason = (
"用户指定了已标定风口的双轴百分比位置。"
@@ -275,6 +275,7 @@ class NativeCanRelativeResolver:
"target": target,
"operation": "set",
"parameters": parameters,
"parse_source": "local_rule",
"reason": f"用户要求{label}{verb}{delta};基于当前状态 {current} 计算绝对目标 {target_value}{',已按允许范围收敛' if clamped else ''}。",
"relative_resolution": {
"direction": direction,
@@ -183,6 +183,9 @@ class NativeCanRuntime:
"zone": target,
},
"parameters": dict(action.get("parameters", {})),
"parse_source": str(
action.get("parse_source", "local_rule")
),
"timeout_ms": (
20_000
if capability_name
@@ -417,6 +420,8 @@ class NativeCanRuntime:
return f"seat driver massage strength {strength}"
return f"seat driver massage mode {parameters['mode']}"
if capability == "seat.set_driver_axis_native":
if parameters["direction"] == "stop":
return f"seat driver {parameters['axis']} stop"
return (
f"seat driver {parameters['axis']} {parameters['direction']} "
f"{parameters.get('duration_ms', 500)}"
@@ -1129,15 +1134,30 @@ class NativeCanExecutor:
"feedback ID and a target-value match contract"
)
special_handler = command["signals"].get("special_handler")
if special_handler == "window_position":
feedback_required = self.feedback_required
if not feedback_required and special_handler in {
None,
"shade_preset",
}:
# Wireless MQTT uses the state topic as an asynchronous
# source of truth. Fixed frames and self-terminating
# shade presets must not hold the HTTP request open
# while waiting for the same state stream synchronously.
sent_frames = self._send_command(bus, command)
feedback = None
elif special_handler == "window_position":
sent_frames, feedback = self._execute_window_position(bus, command)
elif special_handler == "driver_seat_axis":
sent_frames, feedback = self._execute_driver_seat_axis(
bus, command
bus,
command,
wait_for_feedback=feedback_required,
)
elif special_handler == "driver_seat_memory_recall":
sent_frames, feedback = self._execute_driver_seat_memory(
bus, command
bus,
command,
wait_for_completion=feedback_required,
)
elif special_handler == "shade_preset":
sent_frames, feedback = self._execute_shade_preset(
@@ -1151,7 +1171,6 @@ class NativeCanExecutor:
sent_frames = self._send_command(bus, command)
feedback = self._wait_feedback(bus, command)
feedback_match = self._feedback_matches(command, feedback)
feedback_required = self.feedback_required
verified = feedback is not None and feedback_match is True
delivered = bool(sent_frames)
succeeded = verified or (delivered and not feedback_required)
@@ -1167,17 +1186,26 @@ class NativeCanExecutor:
publication_start:
]
)
delivery_status = "submitted"
if not feedback_required and delivered:
delivery_status = (
"published"
if mqtt_messages
and all(
message.get("delivery_status") == "published"
for message in mqtt_messages
)
else "queued"
)
results.append(
{
"ref": operation["id"],
"domain": function_config["spec"]["domain"],
"status": "succeeded" if succeeded else "failed",
"message": (
"MQTT 白名单控制帧已发布,并收到匹配的车辆状态反馈。"
if not feedback_required and feedback_match is True
else "MQTT 白名单控制帧已发布;已收到状态帧,但目标值尚未确认。"
if not feedback_required and feedback is not None
else "MQTT 白名单控制帧已发布;车辆状态反馈尚未确认。"
"MQTT 白名单控制帧已由 Broker 确认;车辆效果以状态话题回读为准。"
if not feedback_required and delivery_status == "published"
else "MQTT 白名单控制帧已进入客户端发布队列;车辆效果以状态话题回读为准。"
if not feedback_required
else "CAN 盒已提交车端控制帧并观察到匹配反馈。"
if feedback_match is True
@@ -1197,11 +1225,7 @@ class NativeCanExecutor:
"feedback_match": feedback_match,
"feedback_state": self._feedback_state(feedback),
"feedback_required": feedback_required,
"delivery_status": (
"published"
if not feedback_required and delivered
else "submitted"
),
"delivery_status": delivery_status,
"physical_effect_confirmed": verified,
"control_gate": (
"compiled_action_and_frame_whitelist"
@@ -1260,7 +1284,11 @@ class NativeCanExecutor:
return sent
def _execute_driver_seat_axis(
self, bus: CanBus, command: dict[str, Any]
self,
bus: CanBus,
command: dict[str, Any],
*,
wait_for_feedback: bool = True,
) -> tuple[list[CanFrame], CanFrame | None]:
"""Send bounded motion and attempt all three stop frames in a finally path."""
@@ -1313,10 +1341,14 @@ class NativeCanExecutor:
f"{len(stop_errors)}/3 forced-stop frame(s) failed"
)
raise RuntimeError("; ".join(details))
return sent, self._wait_feedback(bus, command)
return sent, self._wait_feedback(bus, command) if wait_for_feedback else None
def _execute_driver_seat_memory(
self, bus: CanBus, command: dict[str, Any]
self,
bus: CanBus,
command: dict[str, Any],
*,
wait_for_completion: bool = True,
) -> tuple[list[CanFrame], CanFrame | None]:
"""Require fresh initialization, then replay the learned 16/15 sequence."""
@@ -1366,9 +1398,13 @@ class NativeCanExecutor:
)
if index + 1 < len(sequence):
time.sleep(int(special["period_ms"]) / 1_000)
feedback = self._wait_for_id(
feedback = (
self._wait_for_id(
bus, 0x494, int(special["post_status_timeout_ms"])
)
if wait_for_completion
else None
)
return sent, feedback
def _execute_shade_preset(
+518 -17
View File
@@ -8,6 +8,7 @@ import re
import sys
import time
import uuid
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from copy import deepcopy
from datetime import UTC, datetime
@@ -177,7 +178,7 @@ UPDATE_MODE_PROFILE_PREFIX = re.compile(
UPDATE_MODE_PROFILE_BA = re.compile(
r"^(?:请(?:帮我)?|帮我)?(?:把|将)"
r"(?P<name>[^,。,.!!\s]{1,64}?模式)"
r"(?:的)?(?P<change>.+)$"
r"的(?P<change>.+)$"
)
RESTORE_MODE_PROFILE_PREFIX = re.compile(
r"^(?:请(?:帮我)?|帮我)?(?:恢复|还原|重置|初始化)"
@@ -1066,6 +1067,340 @@ class SeatDemoService:
return MqttOtaSeatExecutor(executor, resolved_bus_factory)
return executor
def preview_native_can(
self,
utterance: str,
provider: str = "auto",
conversation_id: str | None = None,
execution_mode: str = "dry_run",
approval_token: str | None = None,
vehicle_id: str | None = None,
simulation_fault: str = "normal",
*,
hardware_client_id: str | None = None,
hardware_access_bypass: bool = True,
reasoning_effort: str | None = None,
control_transport: str = WIRED_CAN_TRANSPORT,
on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
) -> dict[str, Any]:
"""Validate and execute deterministic controls before hosted parsing."""
utterance = utterance.strip()
if not utterance:
raise ValueError("utterance must not be empty")
if provider not in {"auto", "openai", "local"}:
raise ValueError("provider must be auto, openai, or local")
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)
control_plane_request = any(
matcher(utterance) is not None
for matcher in (
self._parse_mode_profile_save_name,
self._parse_mode_profile_create_name,
self._parse_mode_profile_restore_name,
self._parse_mode_profile_update,
)
)
if control_plane_request:
result = self.process(
utterance,
provider=provider,
conversation_id=conversation_id,
execution_mode=execution_mode,
approval_token=approval_token,
vehicle_id=vehicle_profile.id,
simulation_fault=simulation_fault,
hardware_client_id=hardware_client_id,
hardware_access_bypass=hardware_access_bypass,
reasoning_effort=reasoning_effort,
control_transport=control_transport,
_on_commands_validated=on_commands_validated,
)
result.update(
{
"phase": "control_plane_complete",
"preview_request_id": result["request_id"],
"unresolved_clauses": [],
"supplement_required": False,
}
)
self._persist(result)
return result
context = self._context_for_vehicle(
self.conversations.snapshot(conversation_id),
vehicle_profile,
)
clause_results = self._local_clause_results(utterance, context)
unresolved_clauses = [
clause
for clause, _, status in clause_results
if status != "complete"
]
started = time.perf_counter()
def publish_local_commands(validated: dict[str, Any]) -> None:
if on_commands_validated is None:
return
event = deepcopy(validated)
event.update(
{
"phase": "deterministic_commands_validated",
"provider_requested": provider,
"unresolved_clauses": unresolved_clauses,
"supplement_required": bool(
provider != "local" and unresolved_clauses
),
}
)
on_commands_validated(event)
result = self.process(
utterance,
provider="local",
conversation_id=conversation_id,
execution_mode=execution_mode,
approval_token=approval_token,
vehicle_id=vehicle_profile.id,
simulation_fault=simulation_fault,
hardware_client_id=hardware_client_id,
hardware_access_bypass=hardware_access_bypass,
reasoning_effort=reasoning_effort,
control_transport=control_transport,
_local_preview=True,
_allow_empty_control_result=True,
_on_commands_validated=publish_local_commands,
)
elapsed_ms = round((time.perf_counter() - started) * 1000, 3)
supplement_required = bool(
provider != "local" and unresolved_clauses
)
result.update(
{
"phase": "deterministic_preview",
"provider_requested": provider,
"preview_request_id": result["request_id"],
"preview_elapsed_ms": elapsed_ms,
"unresolved_clauses": unresolved_clauses,
"supplement_required": supplement_required,
}
)
result["decision_summary"] = [
*result.get("decision_summary", []),
(
"本地规则阶段已完成白名单校验并先行处理 "
f"{sum(len((config.get('spec') or {}).get('operations', [])) for config in result.get('function_configs', {}).values())} 条 CAN 控制指令。"
),
]
self._persist(result)
manifest = {
"version": 1,
"preview_request_id": result["request_id"],
"utterance": utterance,
"provider": provider,
"conversation_id": conversation_id,
"execution_mode": execution_mode,
"vehicle_id": vehicle_profile.id,
"simulation_fault": simulation_fault,
"hardware_client_id": hardware_client_id,
"reasoning_effort": reasoning_effort,
"control_transport": control_transport,
"unresolved_clauses": unresolved_clauses,
"preview_control_signatures": result.get(
"parsed_control_signatures", []
),
}
self._atomic_write(
self.artifact_root / result["request_id"] / "preview.json",
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
)
return result
def supplement_native_can(
self,
preview_request_id: str,
*,
provider: str | None = None,
approval_token: str | None = None,
hardware_client_id: str | None = None,
hardware_access_bypass: bool = True,
reasoning_effort: str | None = None,
on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
) -> dict[str, Any]:
"""Parse only unresolved clauses and merge them with the local result."""
if not MODE_EDITOR_REQUEST_ID_PATTERN.fullmatch(preview_request_id):
raise ValueError("invalid preview request id")
preview_dir = self.artifact_root / preview_request_id
try:
manifest = json.loads(
(preview_dir / "preview.json").read_text(encoding="utf-8")
)
preview = json.loads(
(preview_dir / "result.json").read_text(encoding="utf-8")
)
except (OSError, json.JSONDecodeError) as exc:
raise ValueError("native CAN preview not found") from exc
if (
not isinstance(manifest, dict)
or manifest.get("version") != 1
or manifest.get("preview_request_id") != preview_request_id
or not isinstance(preview, dict)
or preview.get("request_id") != preview_request_id
):
raise ValueError("invalid native CAN preview manifest")
selected_provider = str(manifest.get("provider") or "auto")
if provider is not None and provider != selected_provider:
raise ValueError("supplement provider does not match preview")
selected_effort = manifest.get("reasoning_effort")
if reasoning_effort is not None and reasoning_effort != selected_effort:
raise ValueError("supplement reasoning effort does not match preview")
expected_client_id = manifest.get("hardware_client_id")
if hardware_client_id != expected_client_id:
raise ValueError("supplement hardware client does not match preview")
unresolved_clauses = [
str(item).strip()
for item in manifest.get("unresolved_clauses", [])
if str(item).strip()
]
if not unresolved_clauses:
raise ValueError("native CAN preview has no unresolved clauses")
excluded_signatures = {
tuple(item)
for item in manifest.get("preview_control_signatures", [])
if isinstance(item, list)
}
def publish_cloud_commands(validated: dict[str, Any]) -> None:
if on_commands_validated is None:
return
event = self._merge_native_can_phases(preview, validated)
event.update(
{
"phase": "supplement_commands_validated",
"preview_request_id": preview_request_id,
"supplement_of": preview_request_id,
"supplement_required": False,
}
)
on_commands_validated(event)
supplement = self.process(
",".join(unresolved_clauses),
provider=selected_provider,
conversation_id=str(manifest["conversation_id"]),
execution_mode=str(manifest.get("execution_mode") or "dry_run"),
approval_token=approval_token,
vehicle_id=str(manifest["vehicle_id"]),
simulation_fault=str(manifest.get("simulation_fault") or "normal"),
hardware_client_id=hardware_client_id,
hardware_access_bypass=hardware_access_bypass,
reasoning_effort=(
str(selected_effort) if selected_effort is not None else None
),
control_transport=str(
manifest.get("control_transport") or WIRED_CAN_TRANSPORT
),
_excluded_control_signatures=excluded_signatures,
_model_parse_source="cloud_supplement",
_allow_empty_control_result=True,
_on_commands_validated=publish_cloud_commands,
)
merged = self._merge_native_can_phases(preview, supplement)
merged.update(
{
"phase": "two_stage_complete",
"utterance": str(manifest["utterance"]),
"provider_requested": selected_provider,
"preview_request_id": preview_request_id,
"supplement_of": preview_request_id,
"supplement_request_id": supplement["request_id"],
"supplement_required": False,
"unresolved_clauses": unresolved_clauses,
}
)
self._persist(merged)
return merged
@staticmethod
def _merge_native_can_phases(
preview: dict[str, Any],
supplement: dict[str, Any],
) -> dict[str, Any]:
"""Create one durable result without collapsing same-domain phases."""
merged = deepcopy(supplement)
def phase_documents(
local_documents: dict[str, Any] | None,
cloud_documents: dict[str, Any] | None,
) -> dict[str, Any]:
return {
**{
f"local_{key}": deepcopy(value)
for key, value in (local_documents or {}).items()
},
**{
f"cloud_{key}": deepcopy(value)
for key, value in (cloud_documents or {}).items()
},
}
configs = phase_documents(
preview.get("function_configs"),
supplement.get("function_configs"),
)
plans = phase_documents(
preview.get("command_plans"),
supplement.get("command_plans"),
)
merged["function_configs"] = configs
merged["command_plans"] = plans
merged["function_config"] = next(iter(configs.values()), None)
merged["command_plan"] = next(iter(plans.values()), None)
merged["decision_summary"] = [
*preview.get("decision_summary", []),
*supplement.get("decision_summary", []),
]
merged["trace_events"] = [
*preview.get("trace_events", []),
*supplement.get("trace_events", []),
]
merged["validation"] = [
*preview.get("validation", []),
*supplement.get("validation", []),
]
merged["parsed_control_signatures"] = [
*preview.get("parsed_control_signatures", []),
*supplement.get("parsed_control_signatures", []),
]
merged["phase_execution_reports"] = {
"local_rule": deepcopy(preview.get("execution_report")),
"cloud_supplement": deepcopy(supplement.get("execution_report")),
}
if supplement.get("execution_report") is None:
merged["execution_report"] = deepcopy(preview.get("execution_report"))
successful = {"succeeded", "partially_succeeded", "no_action"}
preview_ok = preview.get("status") in successful
supplement_ok = supplement.get("status") in successful
has_any_commands = bool(configs and plans)
if preview_ok and supplement_ok:
merged["status"] = "succeeded" if has_any_commands else "no_action"
elif preview_ok and preview.get("function_configs"):
merged["status"] = "partially_succeeded"
merged["local_preview"] = {
"request_id": preview.get("request_id"),
"elapsed_ms": preview.get("preview_elapsed_ms"),
"command_count": sum(
len((config.get("spec") or {}).get("operations", []))
for config in preview.get("function_configs", {}).values()
),
}
return merged
def process(
self,
utterance: str,
@@ -1080,6 +1415,11 @@ class SeatDemoService:
hardware_access_bypass: bool = True,
reasoning_effort: str | None = None,
control_transport: str = WIRED_CAN_TRANSPORT,
_local_preview: bool = False,
_excluded_control_signatures: set[tuple[Any, ...]] | None = None,
_model_parse_source: str = "cloud_model",
_allow_empty_control_result: bool = False,
_on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport)
# Creating a reusable scene is a configuration workflow. Synthesize
@@ -1099,6 +1439,11 @@ class SeatDemoService:
simulation_fault,
reasoning_effort,
control_transport,
_local_preview,
_excluded_control_signatures,
_model_parse_source,
_allow_empty_control_result,
_on_commands_validated,
)
if hardware_access_bypass or self._driver_only_vehicle_execution_active(
control_transport
@@ -1114,6 +1459,11 @@ class SeatDemoService:
simulation_fault,
reasoning_effort,
control_transport,
_local_preview,
_excluded_control_signatures,
_model_parse_source,
_allow_empty_control_result,
_on_commands_validated,
)
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
with self.hardware_execution.reserve():
@@ -1128,6 +1478,11 @@ class SeatDemoService:
simulation_fault,
reasoning_effort,
control_transport,
_local_preview,
_excluded_control_signatures,
_model_parse_source,
_allow_empty_control_result,
_on_commands_validated,
)
def _process_once(
@@ -1141,6 +1496,11 @@ class SeatDemoService:
simulation_fault: str = "normal",
reasoning_effort: str | None = None,
control_transport: str = WIRED_CAN_TRANSPORT,
local_preview: bool = False,
excluded_control_signatures: set[tuple[Any, ...]] | None = None,
model_parse_source: str = "cloud_model",
allow_empty_control_result: bool = False,
on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport)
utterance = utterance.strip()
@@ -1298,6 +1658,8 @@ class SeatDemoService:
context,
vehicle_profile,
reasoning_effort=reasoning_effort,
local_preview=local_preview,
model_parse_source=model_parse_source,
)
if self._is_dynamic_mode_stop_request(utterance):
parsed = self._apply_dynamic_mode_stop_request(parsed, utterance)
@@ -1305,6 +1667,7 @@ class SeatDemoService:
parsed = self._apply_mode_profiles(
parsed, matched_modes, context, vehicle_profile
)
parsed = self._mark_control_parse_source(parsed, "local_rule")
relative_resolution = (
self.native_can_relative_resolver.resolve(utterance, context)
if self.native_can_relative_resolver.matches(utterance)
@@ -1323,6 +1686,15 @@ class SeatDemoService:
relative_resolution.claimed_controls if relative_resolution else None
),
)
if local_preview:
parsed["ambiguities"] = []
parsed["unresolved_spans"] = []
parsed["model_requests"] = []
if excluded_control_signatures:
parsed = self._filter_excluded_controls(
parsed,
excluded_control_signatures,
)
parsed = apply_language_safety_guards(parsed, utterance)
parsed = self._partition_native_actions(
parsed,
@@ -1335,6 +1707,47 @@ class SeatDemoService:
)
),
)
result["parsed_control_signatures"] = [
list(self._control_signature(action))
for action in [
*parsed.get("actions", []),
*parsed.get("domain_actions", []),
*parsed.get("native_can_actions", []),
]
]
has_control = any(
parsed.get(key)
for key in ("actions", "domain_actions", "native_can_actions")
)
if allow_empty_control_result and not has_control:
result["provider_used"] = provider_used
result["decision_summary"] = [
*parsed.get("decision_summary", []),
"当前阶段没有新增的已验证 CAN 控制指令。",
]
result["deferred_requests"] = parsed.get("deferred_requests", [])
result["model_requests"] = parsed.get("model_requests", [])
result["intent_ir"] = build_intent_ir(
parsed,
utterance,
request_id,
provider_used,
self.semantic_capability_metadata,
)
result["validation"] = self.contracts.validate(result["intent_ir"])
result["status"] = "no_action"
self._trace(
traces,
request_id,
"intent_parse",
"succeeded",
"当前阶段未产生新的白名单控制动作。",
started,
{"provider": provider_used, "control_count": 0},
domain="orchestration",
)
self._persist(result)
return result
if hardware_request and not control_plane_request:
if (
control_transport == WIRELESS_MQTT_TRANSPORT
@@ -1505,6 +1918,7 @@ class SeatDemoService:
simulation_fault=simulation_fault,
vehicle_profile=vehicle_profile,
control_transport=control_transport,
on_commands_validated=on_commands_validated,
)
intent_ir = build_intent_ir(
@@ -1776,6 +2190,8 @@ class SeatDemoService:
self._persist(result)
return result
self._emit_validated_commands(result, on_commands_validated)
safety_started = time.perf_counter()
self._trace(
traces,
@@ -3395,6 +3811,21 @@ class SeatDemoService:
result["deferred_requests"] = deferred_requests
return result
@staticmethod
def _emit_validated_commands(
result: dict[str, Any],
callback: Callable[[dict[str, Any]], None] | None,
) -> None:
"""Expose fully compiled commands before physical execution begins."""
if callback is None:
return
event = deepcopy(result)
event["status"] = "processing"
event["phase"] = "commands_validated"
event["execution_report"] = None
callback(event)
def _process_orchestrated(
self,
utterance: str,
@@ -3411,6 +3842,7 @@ class SeatDemoService:
simulation_fault: str = "normal",
vehicle_profile: VehicleProfile | None = None,
control_transport: str = WIRED_CAN_TRANSPORT,
on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
) -> dict[str, Any]:
"""Run isolated adapters under the selected physical-bus policy."""
vehicle_profile = vehicle_profile or self.vehicle_profiles.default()
@@ -3669,6 +4101,8 @@ class SeatDemoService:
self._persist(result)
return result
self._emit_validated_commands(result, on_commands_validated)
self._trace(
traces,
request_id,
@@ -3842,7 +4276,7 @@ class SeatDemoService:
"execute",
"succeeded" if execution_succeeded and not report_issues else "failed",
(
"无线 MQTT 白名单控制帧已发布;车辆实际状态以状态话题回读为准。"
"无线 MQTT 白名单控制帧已提交;车辆实际状态以状态话题回读为准。"
if (
hardware_request
and control_transport == WIRELESS_MQTT_TRANSPORT
@@ -4385,16 +4819,29 @@ class SeatDemoService:
)
return merged
def _interpret(
@staticmethod
def _mark_control_parse_source(
parsed: dict[str, Any],
source: str,
*,
overwrite: bool = False,
) -> dict[str, Any]:
"""Attach per-control provenance without changing semantic content."""
marked = deepcopy(parsed)
for key in ("actions", "domain_actions", "native_can_actions"):
for action in marked.get(key, []):
if overwrite or not action.get("parse_source"):
action["parse_source"] = source
return marked
def _local_clause_results(
self,
utterance: str,
provider: str,
context: dict[str, Any],
vehicle_profile: VehicleProfile | None = None,
*,
reasoning_effort: str | None = None,
) -> tuple[dict[str, Any], str, str | None]:
vehicle_profile = vehicle_profile or self.vehicle_profiles.default()
) -> list[tuple[str, dict[str, Any], str]]:
"""Parse each clause deterministically and preserve inherited seat scope."""
clause_results: list[tuple[str, dict[str, Any], str]] = []
def parse_clause(clause: str) -> dict[str, Any]:
@@ -4413,7 +4860,7 @@ class SeatDemoService:
relative.claimed_controls if relative else None
),
)
return local
return self._mark_control_parse_source(local, "local_rule")
def has_seat_domain(parsed: dict[str, Any]) -> bool:
if parsed.get("actions") or "seat" in parsed.get("detected_domains", []):
@@ -4441,12 +4888,6 @@ class SeatDemoService:
if clause_has_seat and explicit_driver != explicit_passenger:
inherited_seat_scope = "driver" if explicit_driver else "passenger"
elif clause_has_seat and inherited_seat_scope:
# Chinese compound controls commonly state the seat once and
# then list axes or thermal functions after commas. Carry the
# last explicit seat scope only into later seat clauses; this
# prevents a passenger request from silently falling through
# to the CH1 driver default while leaving climate/door/window
# clauses untouched.
scope_label = (
"主驾座椅"
if inherited_seat_scope == "driver"
@@ -4457,10 +4898,59 @@ class SeatDemoService:
clause_results.append(
(clause, local, self._local_clause_status(local, clause))
)
return clause_results
@classmethod
def _trusted_local_preview(
cls,
clause_results: list[tuple[str, dict[str, Any], str]],
) -> dict[str, Any]:
"""Keep deterministic controls while deferring incomplete text to GPT."""
trusted = cls._merge_parsed_results(
[parsed for _, parsed, status in clause_results if status != "unparsed"]
)
# Ambiguities and unresolved spans belong to the supplement phase. A
# deterministic action still goes through the normal schema, compiler,
# whitelist and physical execution gates before it can be sent.
trusted["ambiguities"] = []
trusted["unresolved_spans"] = []
trusted["model_requests"] = []
return trusted
@classmethod
def _filter_excluded_controls(
cls,
parsed: dict[str, Any],
excluded: set[tuple[Any, ...]],
) -> dict[str, Any]:
filtered = deepcopy(parsed)
for key in ("actions", "domain_actions", "native_can_actions"):
filtered[key] = [
action
for action in filtered.get(key, [])
if cls._control_signature(action) not in excluded
]
return filtered
def _interpret(
self,
utterance: str,
provider: str,
context: dict[str, Any],
vehicle_profile: VehicleProfile | None = None,
*,
reasoning_effort: str | None = None,
local_preview: bool = False,
model_parse_source: str = "cloud_model",
) -> tuple[dict[str, Any], str, str | None]:
vehicle_profile = vehicle_profile or self.vehicle_profiles.default()
clause_results = self._local_clause_results(utterance, context)
trusted_local = self._merge_parsed_results(
[parsed for _, parsed, status in clause_results if status != "unparsed"]
)
preview_local = self._trusted_local_preview(clause_results)
all_local = self._merge_parsed_results(
[parsed for _, parsed, _ in clause_results]
)
@@ -4474,7 +4964,13 @@ class SeatDemoService:
safe_fallback = trusted_local if trusted_controls else all_local
if provider == "local" or not fallback_clauses:
return (
all_local if provider == "local" else trusted_local,
(
preview_local
if provider == "local" and local_preview
else all_local
if provider == "local"
else trusted_local
),
"local_rules",
None,
)
@@ -4505,6 +5001,11 @@ class SeatDemoService:
vehicle_profile,
utterance=fallback_utterance,
)
model = self._mark_control_parse_source(
model,
model_parse_source,
overwrite=True,
)
return (
self._merge_local_and_model_intent(trusted_local, model),
f"local_rules+openai:{self.openai.model}",
@@ -411,6 +411,10 @@ def mqtt_control_payload(
"data": frame.data.hex(" ").upper(),
"repeat": 1,
"intervalMs": 0,
# Vehicle state is consumed asynchronously from can/state by the
# Cognitive Agent. Do not serialize Android's command worker on a
# per-command response wait as well.
"expectResponse": False,
"responseTimeoutMs": bounded_timeout_ms,
},
ensure_ascii=False,
@@ -788,14 +792,19 @@ class MqttCanTransport:
)
if int(getattr(result, "rc", 0)) != 0:
raise CanGatewayError(f"MQTT CAN publish failed with code {result.rc}")
if hasattr(result, "wait_for_publish"):
published = result.wait_for_publish(timeout=2.0)
if published is False:
raise CanGatewayError("MQTT CAN publish confirmation timed out")
# Paho already accepted the payload into its network-loop queue when
# publish() returns rc == MQTT_ERR_SUCCESS. Waiting here serializes
# every repeated CAN frame and previously added up to two seconds per
# frame. Observe an already-available acknowledgement without ever
# blocking the control pipeline.
is_published = getattr(result, "is_published", None)
broker_confirmed = bool(is_published()) if callable(is_published) else False
return {
"topic": self.settings.control_topic,
"qos": self.settings.qos,
"retain": False,
"delivery_status": "published" if broker_confirmed else "queued",
"message_id": getattr(result, "mid", None),
"payload": json.loads(raw_payload),
}
@@ -876,7 +885,7 @@ class MqttCanBus:
@property
def published_messages(self) -> tuple[dict[str, Any], ...]:
"""Return this bus view's confirmed MQTT publications in send order."""
"""Return this bus view's accepted MQTT publications in send order."""
return tuple(deepcopy(self._published_messages))
+207 -18
View File
@@ -362,7 +362,14 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if path.startswith("/api/mode-profiles/"):
self._handle_mode_profile_post(path)
return
if path != "/api/requests":
supplement_match = re.fullmatch(
r"/api/requests/(?P<request_id>req-[a-z0-9-]+)/supplement",
path,
)
if (
path not in {"/api/requests", "/api/requests/preview"}
and not supplement_match
):
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
return
@@ -397,40 +404,82 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
payload = self._read_json_body()
utterance = str(payload.get("utterance", ""))
source = str(payload.get("client_type", "head_unit"))
if utterance:
self.vehicle_display_sync.update_task(
status="running",
utterance=utterance,
source=source,
)
result = self.service.process(
utterance,
str(payload.get("provider", "auto")),
str(payload["conversation_id"]) if payload.get("conversation_id") else None,
str(payload.get("execution_mode", "dry_run")),
# Hardware authorization is a server deployment policy. The
# browser no longer renders or transmits an approval secret.
approval_token=os.getenv("COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"),
vehicle_id=(
str(payload["vehicle_id"])
if payload.get("vehicle_id")
else None
),
simulation_fault=str(payload.get("simulation_fault", "normal")),
hardware_client_id = (
str(payload["hardware_client_id"])
if payload.get("hardware_client_id")
else None
),
hardware_access_bypass=self._is_unproxied_loopback_request(),
)
reasoning_effort = (
str(payload["reasoning_effort"])
if payload.get("reasoning_effort") is not None
else None
)
common_arguments = {
"utterance": utterance,
"provider": str(payload.get("provider", "auto")),
"conversation_id": (
str(payload["conversation_id"])
if payload.get("conversation_id")
else None
),
control_transport=str(
"execution_mode": str(
payload.get("execution_mode", "dry_run")
),
# Hardware authorization is a server deployment policy. The
# browser never receives or transmits this secret.
"approval_token": os.getenv(
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"
),
"vehicle_id": (
str(payload["vehicle_id"])
if payload.get("vehicle_id")
else None
),
"simulation_fault": str(
payload.get("simulation_fault", "normal")
),
"hardware_client_id": hardware_client_id,
"hardware_access_bypass": self._is_unproxied_loopback_request(),
"reasoning_effort": reasoning_effort,
"control_transport": str(
payload.get("control_transport", "wired_can")
),
}
if "application/x-ndjson" in self.headers.get("Accept", ""):
self._stream_control_request(
path,
supplement_match,
payload,
common_arguments,
source=source,
)
return
if path == "/api/requests/preview":
result = self.service.preview_native_can(**common_arguments)
elif supplement_match:
result = self.service.supplement_native_can(
supplement_match.group("request_id"),
provider=(
str(payload["provider"])
if payload.get("provider")
else None
),
approval_token=os.getenv(
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"
),
hardware_client_id=hardware_client_id,
hardware_access_bypass=self._is_unproxied_loopback_request(),
reasoning_effort=reasoning_effort,
)
else:
result = self.service.process(**common_arguments)
utterance = str(result.get("utterance") or utterance)
self.vehicle_display_sync.update_task(
status=str(result.get("status", "succeeded")),
utterance=utterance,
@@ -489,6 +538,146 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
HTTPStatus.INTERNAL_SERVER_ERROR,
)
def _stream_control_request(
self,
path: str,
supplement_match: re.Match[str] | None,
payload: dict[str, object],
common_arguments: dict[str, object],
*,
source: str,
) -> None:
"""Stream validated command plans before physical execution finishes."""
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("X-Accel-Buffering", "no")
self._send_security_headers()
self.end_headers()
stream_open = True
def emit(event: dict[str, object]) -> None:
nonlocal stream_open
if not stream_open:
return
content = (json.dumps(event, ensure_ascii=False) + "\n").encode(
"utf-8"
)
try:
self.wfile.write(content)
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
# The submitted control request must continue even if the
# browser closes while the physical execution is in progress.
stream_open = False
def on_commands_validated(result: dict[str, object]) -> None:
emit({"type": "validated_commands", "payload": result})
utterance = str(common_arguments.get("utterance") or "")
try:
if path == "/api/requests/preview":
result = self.service.preview_native_can(
**common_arguments,
on_commands_validated=on_commands_validated,
)
elif supplement_match:
result = self.service.supplement_native_can(
supplement_match.group("request_id"),
provider=(
str(payload["provider"])
if payload.get("provider")
else None
),
approval_token=os.getenv(
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"
),
hardware_client_id=(
str(payload["hardware_client_id"])
if payload.get("hardware_client_id")
else None
),
hardware_access_bypass=self._is_unproxied_loopback_request(),
reasoning_effort=(
str(payload["reasoning_effort"])
if payload.get("reasoning_effort") is not None
else None
),
on_commands_validated=on_commands_validated,
)
else:
result = self.service.process(
**common_arguments,
_on_commands_validated=on_commands_validated,
)
utterance = str(result.get("utterance") or utterance)
self.vehicle_display_sync.update_task(
status=str(result.get("status", "succeeded")),
utterance=utterance,
source=source,
result=result,
)
emit({"type": "result", "payload": result})
except HardwareAccessRequiredError as exc:
self.vehicle_display_sync.update_task(
status="failed",
utterance=utterance,
source=source,
error={"code": "hardware_access_required", "message": str(exc)},
)
emit({
"type": "error",
"error": "hardware_access_required",
"message": str(exc),
"access_state": exc.state,
})
except HardwareBusyError as exc:
self.vehicle_display_sync.update_task(
status="failed",
utterance=utterance,
source=source,
error={"code": "hardware_busy", "message": str(exc)},
)
emit({
"type": "error",
"error": "hardware_busy",
"message": str(exc),
"retry_after_seconds": exc.retry_after_seconds,
})
except HardwareSafetyError as exc:
self.vehicle_display_sync.update_task(
status="failed",
utterance=utterance,
source=source,
error={"code": "hardware_safety_gate", "message": str(exc)},
)
emit({
"type": "error",
"error": "hardware_safety_gate",
"message": str(exc),
})
except (ValueError, json.JSONDecodeError) as exc:
emit({
"type": "error",
"error": "invalid_request",
"message": str(exc),
})
except OpenAIIntentError as exc:
self.log_error("OpenAI intent request failed: %s", exc)
emit({
"type": "error",
"error": "openai_request_failed",
"message": "GPT 意图解析失败,请检查 API Key、模型权限或网络状态。",
})
except Exception as exc: # noqa: BLE001 - stream a terminal event
self.log_error("streaming request failed: %s", exc)
emit({
"type": "error",
"error": "internal_error",
"message": "请求处理失败,请查看服务端日志。",
})
def do_DELETE(self) -> None: # noqa: N802 - stdlib handler API
parsed_url = urlparse(self.path)
path = unquote(parsed_url.path)
+136 -8
View File
@@ -332,18 +332,18 @@ operations:
def test_static_console_and_runtime_endpoint(self) -> None:
with self.opener.open(f"{self.base_url}/", timeout=3) as response:
html = response.read().decode("utf-8")
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-101", timeout=3) as response:
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-103", timeout=3) as response:
app_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/gesture-control.js?v=android-pad-9", timeout=3
) as response:
gesture_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/console-v2.js?v=sync-layout-41", timeout=3
f"{self.base_url}/console-v2.js?v=sync-layout-43", timeout=3
) as response:
console_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/console-v2.css?v=sync-layout-40", timeout=3
f"{self.base_url}/console-v2.css?v=sync-layout-41", timeout=3
) as response:
console_styles = response.read().decode("utf-8")
with self.opener.open(
@@ -378,12 +378,12 @@ operations:
self.assertIn("CMD 0x431 · FB 0x41A · CH1", html)
self.assertIn("CMD 0x431 · FB 0x388 · CH1", html)
self.assertNotIn("透明车体 · 实体设备", html)
self.assertIn('src="/app.js?v=dual-seat-101"', html)
self.assertIn('src="/app.js?v=dual-seat-103"', html)
self.assertIn('href="/styles.css?v=dual-seat-16"', html)
self.assertIn('src="/gesture-control.js?v=android-pad-9"', html)
self.assertNotIn('class="vehicle-system-hud"', html)
self.assertIn('src="/console-v2.js?v=sync-layout-41"', html)
self.assertIn('href="/console-v2.css?v=sync-layout-40"', html)
self.assertIn('src="/console-v2.js?v=sync-layout-43"', html)
self.assertIn('href="/console-v2.css?v=sync-layout-41"', html)
self.assertIn('<body class="console-v2">', html)
self.assertIn('id="v2BootScreen"', html)
self.assertIn('正在加载车机控制与实时状态', html)
@@ -612,7 +612,7 @@ operations:
self.assertIn('client_type: "web_console"', app_script)
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
self.assertIn('"cognitive-agent:module-change"', app_script)
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v202"', service_worker)
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v204"', service_worker)
self.assertIn('"/assets/pets/self-extension-cat.png?v=3"', service_worker)
self.assertIn(".then(() => self.skipWaiting())", service_worker)
self.assertNotIn("const cached = await caches.match(request)", service_worker)
@@ -852,7 +852,7 @@ operations:
) as response:
extension_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/app.js?v=dual-seat-101", timeout=3
f"{self.base_url}/app.js?v=dual-seat-103", timeout=3
) as response:
app_script = response.read().decode("utf-8")
@@ -1314,6 +1314,134 @@ operations:
self.assertEqual(result["provider_used"], "local_rules_fallback")
self.assertEqual(result["execution_report"]["spec"]["mode"], "dry_run")
def test_two_stage_http_api_returns_local_commands_before_cloud_supplement(
self,
) -> None:
original_api_key = self.service.openai.api_key
self.service.openai.api_key = "two-stage-http-test-key"
preview_request = urllib.request.Request(
f"{self.base_url}/api/requests/preview",
method="POST",
headers={"Content-Type": "application/json"},
data=json.dumps(
{
"utterance": "空调温度设置为24度,播放音乐",
"provider": "openai",
"conversation_id": "conv-e2e-two-stage-http",
"execution_mode": "dry_run",
"reasoning_effort": "medium",
"client_type": "web_console",
}
).encode("utf-8"),
)
model_output = {
"detected_domains": ["ambient_light"],
"requests": [
{
"domain": "ambient_light",
"capability": "ambient_light.set_power",
"target_scope": "vehicle",
"operation": "set",
"duration_ms": None,
"parameters": [{"name": "state", "value": "on"}],
"reason": "补充播放场景氛围灯。",
}
],
"ambiguities": [],
"decision_summary": ["仅补充本地未解析片段。"],
"confidence": 0.9,
}
try:
with patch.object(
self.service.openai,
"interpret",
side_effect=AssertionError("preview must not invoke GPT"),
):
with self.opener.open(preview_request, timeout=3) as response:
preview = json.loads(response.read().decode("utf-8"))
self.assertEqual(response.status, 201)
local_operation = next(
operation
for config in preview["function_configs"].values()
for operation in config["spec"]["operations"]
)
self.assertEqual(local_operation["parse_source"], "local_rule")
self.assertTrue(preview["supplement_required"])
supplement_request = urllib.request.Request(
f"{self.base_url}/api/requests/{preview['request_id']}/supplement",
method="POST",
headers={"Content-Type": "application/json"},
data=json.dumps(
{
"provider": "openai",
"reasoning_effort": "medium",
"client_type": "web_console",
}
).encode("utf-8"),
)
with patch.object(
self.service.openai,
"interpret",
return_value=model_output,
) as interpret:
with self.opener.open(supplement_request, timeout=3) as response:
result = json.loads(response.read().decode("utf-8"))
self.assertEqual(response.status, 201)
self.assertEqual(interpret.call_args.args[0], "播放音乐")
finally:
self.service.openai.api_key = original_api_key
self.assertEqual(result["phase"], "two_stage_complete")
sources = {
operation["capability"]: operation["parse_source"]
for config in result["function_configs"].values()
for operation in config["spec"]["operations"]
}
self.assertEqual(sources["climate.set_temperature"], "local_rule")
self.assertEqual(sources["ambient_light.set_power"], "cloud_supplement")
def test_preview_streams_validated_commands_before_terminal_result(self) -> None:
request = urllib.request.Request(
f"{self.base_url}/api/requests/preview",
method="POST",
headers={
"Content-Type": "application/json",
"Accept": "application/x-ndjson",
},
data=json.dumps(
{
"utterance": "空调24度、风量2档",
"provider": "local",
"conversation_id": "conv-e2e-command-stream",
"execution_mode": "dry_run",
"client_type": "web_console",
},
ensure_ascii=False,
).encode("utf-8"),
)
with self.opener.open(request, timeout=3) as response:
events = [
json.loads(line)
for line in response.read().decode("utf-8").splitlines()
if line.strip()
]
self.assertEqual(
response.headers.get_content_type(),
"application/x-ndjson",
)
self.assertEqual(
[event["type"] for event in events],
["validated_commands", "result"],
)
validated = events[0]["payload"]
self.assertEqual(validated["status"], "processing")
self.assertIsNone(validated["execution_report"])
self.assertTrue(validated["command_plans"])
self.assertEqual(events[1]["payload"]["status"], "succeeded")
def test_frontend_suite_covers_every_physical_seat_capability(self) -> None:
suite_path = (
REPOSITORY_ROOT
+351
View File
@@ -0,0 +1,351 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from cognitive_agent.application.native_can_intent import (
parse_exact_native_can_command,
)
from cognitive_agent.application.native_can_runtime import (
NativeCanCompileError,
NativeCanRuntime,
)
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
HARDWARE_DISABLED_ENV = {
"COGNITIVE_AGENT_CH1_HIL_ENABLED": "false",
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
}
class Can1SemanticWhitelistTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary_directory = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary_directory.cleanup)
temporary_root = Path(self.temporary_directory.name)
memory_path = temporary_root / "driver-seat-memory.json"
memory_path.write_text(
json.dumps(
{
"version": 1,
"positions": {
str(slot): {
"payload_15": "15 00 00 00 00 00 00 00",
"payload_16": "16 00 00 00 00 00 00 00",
}
for slot in (1, 2, 3)
},
}
),
encoding="utf-8",
)
self.environment = patch.dict(
os.environ,
{
**HARDWARE_DISABLED_ENV,
"COGNITIVE_AGENT_DRIVER_SEAT_MEMORY_PATH": str(memory_path),
},
clear=False,
)
self.environment.start()
self.addCleanup(self.environment.stop)
self.runtime = NativeCanRuntime(REPOSITORY_ROOT)
self.service = SeatDemoService(REPOSITORY_ROOT)
self.service.artifact_root = temporary_root / "artifacts"
@staticmethod
def _valid_exact_commands() -> list[str]:
commands = [f"door rear {state}" for state in ("lock", "unlock")]
commands.extend(
f"window {target} {motion}"
for target in (
"all",
"front",
"rear",
"front-left",
"front-right",
"rear-left",
"rear-right",
)
for motion in ("open", "close", "stop")
)
commands.extend(
f"window {target} position {position}"
for target in (
"front-left",
"front-right",
"rear-left",
"rear-right",
)
for position in (0, 100)
)
commands.extend(("ac on", "ac off"))
commands.extend(f"ac fan {level}" for level in range(1, 8))
commands.extend(
f"ac mode {mode}"
for mode in (
"face",
"face-feet",
"feet",
"feet-defrost",
"defrost",
"face-feet-defrost",
"face-defrost",
)
)
commands.extend(
f"ac temp {target} {temperature}"
for target in ("driver", "passenger", "rear")
for temperature in (18, 32)
)
commands.extend(("ac control manual", "ac control auto"))
commands.extend(
f"outlet {target} percent {horizontal} {vertical}"
for target in (
"driver-left",
"driver-right",
"passenger-left",
"passenger-right",
)
for horizontal, vertical in ((0, 0), (100, 100))
)
commands.extend(
(
"outlet driver-left position -25 -40",
"outlet driver-right position 30 30",
"outlet passenger-left position -10 -40",
"outlet passenger-right position 5 30",
)
)
commands.extend(
f"seat driver {feature} {level}"
for feature in ("vent", "heat")
for level in ("off", "low", "high")
)
commands.append("seat driver massage off")
commands.extend(
f"seat driver massage strength {strength}"
for strength in ("weak", "medium", "strong")
)
commands.extend(
f"seat driver massage mode {mode}"
for mode in ("wave", "pulse", "stretch", "slack", "relax")
)
commands.extend(
(
"seat driver horizontal forward 100",
"seat driver horizontal backward 2000",
"seat driver horizontal stop",
"seat driver height up 100",
"seat driver height down 2000",
"seat driver height stop",
"seat driver backrest forward 100",
"seat driver backrest backward 2000",
"seat driver backrest stop",
"seat driver basin up 100",
"seat driver basin down 2000",
"seat driver basin stop",
)
)
commands.extend(f"seat driver position {slot}" for slot in (1, 2, 3))
commands.extend(
f"seat driver memory recall {slot}" for slot in (1, 2, 3)
)
commands.extend(f"light indoor {state}" for state in ("on", "off"))
commands.extend(
f"light reading all {state}" for state in ("on", "off")
)
commands.extend(f"light ambient {state}" for state in ("on", "off"))
commands.extend(f"light ambient color {value}" for value in (1, 127))
commands.extend(
f"light ambient brightness {value}" for value in (1, 5)
)
commands.extend(
f"shade {motion}" for motion in ("open", "close", "half", "stop")
)
commands.extend(
f"shade position {position}" for position in (0, 1, 50, 99, 100)
)
return commands
def _compile_exact(self, command: str) -> dict:
actions = parse_exact_native_can_command(command)
self.assertEqual(len(actions), 1, command)
action = actions[0]
config = self.runtime.build_function_config(
action["domain"],
actions,
"req-can-one-whitelist",
"local_rules",
)
return self.runtime.compile(config)
@staticmethod
def _debug_commands(result: dict) -> list[str]:
return [
command["signals"]["debug_command"]
for plan in result.get("command_plans", {}).values()
for command in plan["spec"]["commands"]
]
def test_every_documented_exact_command_parses_and_compiles(self) -> None:
commands = self._valid_exact_commands()
self.assertEqual(len(commands), 119)
for command in commands:
with self.subTest(command=command):
plan = self._compile_exact(command)
self.assertEqual(
[
item["signals"]["debug_command"]
for item in plan["spec"]["commands"]
],
[command],
)
def test_out_of_whitelist_exact_values_are_rejected(self) -> None:
commands = (
"door rear open",
"window vehicle open",
"window all position 50",
"window front-left position -1",
"window front-left position 101",
"ac fan 0",
"ac fan 8",
"ac mode auto",
"ac temp driver 17",
"ac temp driver 33",
"ac temp driver 24.5",
"ac control off",
"outlet driver-left percent -1 50",
"outlet driver-left percent 101 50",
"seat driver vent medium",
"seat driver horizontal forward 99",
"seat driver horizontal forward 2001",
"seat driver horizontal stop 500",
"seat driver position 0",
"seat driver position 4",
"light ambient color 0",
"light ambient color 128",
"light ambient brightness 0",
"light ambient brightness 6",
"shade position -1",
"shade position 101",
)
for command in commands:
with self.subTest(command=command):
self.assertEqual(parse_exact_native_can_command(command), [])
def test_calibrated_outlet_values_must_fit_the_selected_outlet(self) -> None:
action = parse_exact_native_can_command(
"outlet driver-left position -26 -40"
)[0]
config = self.runtime.build_function_config(
action["domain"],
[action],
"req-can-one-calibration",
"local_rules",
)
with self.assertRaisesRegex(
NativeCanCompileError, "horizontal_value exceeds the calibrated range"
):
self.runtime.compile(config)
def test_representative_chinese_commands_cover_every_whitelist_family(self) -> None:
cases = (
("后排车门上锁", ["door rear lock"]),
("后排门锁解锁", ["door rear unlock"]),
("打开全部车窗", ["window all open"]),
("关闭前排车窗", ["window front close"]),
("停止后排车窗", ["window rear stop"]),
("把副驾车窗开到35%", ["window front-right position 35"]),
("打开空调", ["ac on"]),
("关闭空调", ["ac off"]),
("风量调到7档", ["ac fan 7"]),
("空调出风模式设为吹脸", ["ac mode face"]),
("空调出风模式设为吹面和吹脚", ["ac mode face-feet"]),
("空调出风模式设为吹脚", ["ac mode feet"]),
("空调出风模式设为吹脚除霜", ["ac mode feet-defrost"]),
("空调出风模式设为除霜", ["ac mode defrost"]),
("空调出风模式设为吹脸和吹脚除霜", ["ac mode face-feet-defrost"]),
("空调出风模式设为吹脸除霜", ["ac mode face-defrost"]),
("主驾空调温度设为18度", ["ac temp driver 18"]),
("副驾空调温度设为32度", ["ac temp passenger 32"]),
("后排空调温度设为24度", ["ac temp rear 24"]),
("空调设置为自动控制", ["ac control auto"]),
("空调设置为手动控制", ["ac control manual"]),
("主驾左风口水平25%上下75%", ["outlet driver-left percent 25 75"]),
("副驾右风口左右25%垂直75%", ["outlet passenger-right percent 25 75"]),
("主驾左风口标定坐标左右-25上下-40", ["outlet driver-left position -25 -40"]),
("打开主驾座椅通风", ["seat driver vent low"]),
("关闭主驾座椅加热", ["seat driver heat off"]),
("主驾座椅加热高档", ["seat driver heat high"]),
("关闭主驾座椅按摩", ["seat driver massage off"]),
("主驾座椅按摩强度调为中等", ["seat driver massage strength medium"]),
("主驾座椅按摩模式设为放松", ["seat driver massage mode relax"]),
("主驾座椅向前移动500毫秒", ["seat driver horizontal forward 500"]),
("主驾座椅高度向下调500毫秒", ["seat driver height down 500"]),
("主驾座椅靠背后仰800毫秒", ["seat driver backrest backward 800"]),
("把主驾座椅坐盆抬高300毫秒", ["seat driver basin up 300"]),
("调用主驾座椅记忆位置2", ["seat driver position 2"]),
("打开室内灯", ["light indoor on"]),
("关闭全部阅读灯", ["light reading all off"]),
("打开氛围灯", ["light ambient on"]),
("关闭氛围灯", ["light ambient off"]),
("把氛围灯颜色编号调到70", ["light ambient color 70"]),
("氛围灯亮度调整为3档", ["light ambient brightness 3"]),
("打开遮阳帘", ["shade open"]),
("关闭遮阳帘", ["shade close"]),
("遮阳帘半开", ["shade half"]),
("停止遮阳帘", ["shade stop"]),
("遮阳帘开到20%", ["shade position 20"]),
("遮阳帘打开30%", ["shade position 30"]),
)
for index, (utterance, expected) in enumerate(cases):
with self.subTest(utterance=utterance):
result = self.service.process(
utterance,
provider="local",
conversation_id=f"conv-can-one-natural-{index}",
execution_mode="dry_run",
)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(self._debug_commands(result), expected)
rear_context = self.service.conversations.snapshot(
"conv-can-one-natural-18"
)
self.assertEqual(
rear_context["domain_devices"]["climate"]["zones"]["rear_row"][
"temperature_c"
],
24,
)
def test_fractional_values_are_not_silently_truncated(self) -> None:
utterances = (
"空调温度设为24.5度",
"风量设为2.5档",
"氛围灯颜色编号调到70.5",
"氛围灯亮度调到3.5档",
)
for index, utterance in enumerate(utterances):
with self.subTest(utterance=utterance):
result = self.service.process(
utterance,
provider="local",
conversation_id=f"conv-can-one-fraction-{index}",
execution_mode="dry_run",
)
self.assertEqual(self._debug_commands(result), [])
if __name__ == "__main__":
unittest.main()
+38
View File
@@ -154,6 +154,44 @@ class Ch1DefaultTargetTests(unittest.TestCase):
"front_right",
)
def test_driver_and_passenger_window_aliases_map_to_the_correct_side(self) -> None:
cases = (
("打开主驾车窗", "front_left", "window.set_motion", {"motion": "open"}),
("打开主驾驶的车窗", "front_left", "window.set_motion", {"motion": "open"}),
("打开副驾车窗", "front_right", "window.set_motion", {"motion": "open"}),
("打开副驾驶的车窗", "front_right", "window.set_motion", {"motion": "open"}),
("把副驾车窗开到35%", "front_right", "window.set_position", {"position_pct": 35}),
)
for index, (utterance, target, capability, parameters) in enumerate(cases):
with self.subTest(utterance=utterance):
result = self.service.process(
utterance,
provider="local",
conversation_id=f"conv-window-occupant-alias-{index}",
execution_mode="dry_run",
)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(set(result["function_configs"]), {"native_window"})
operation = result["function_configs"]["native_window"]["spec"][
"operations"
][0]
self.assertEqual(operation["target"]["zone"], target)
self.assertEqual(operation["capability"], capability)
self.assertEqual(operation["parameters"], parameters)
def test_window_target_does_not_leak_from_a_later_seat_subject(self) -> None:
parsed = merge_native_can_intent({}, "打开副驾车窗和主驾座椅通风")
windows = [
action
for action in parsed["native_can_actions"]
if action["capability"] == "window.set_motion"
]
self.assertEqual(len(windows), 1)
self.assertEqual(windows[0]["target"], "front_right")
self.assertEqual(windows[0]["parameters"], {"motion": "open"})
def test_group_or_non_driver_seat_scope_is_not_treated_as_unscoped(self) -> None:
for index, utterance in enumerate(
("打开前排座椅加热", "打开后排座椅加热", "打开所有座椅加热")
+9 -1
View File
@@ -211,9 +211,17 @@ class DriverSeatNativeCanTests(unittest.TestCase):
{"axis": "height", "direction": "up", "duration_ms": 500},
),
"主驾座椅调整到记忆位置3": (
"seat.recall_driver_memory_native",
"seat.set_driver_position_native",
{"slot": 3},
),
"调用主驾座椅记忆位置2": (
"seat.set_driver_position_native",
{"slot": 2},
),
"恢复主驾座椅到二号记忆位置": (
"seat.set_driver_position_native",
{"slot": 2},
),
}
for utterance, expected in cases.items():
with self.subTest(utterance=utterance):
+132 -1
View File
@@ -27,9 +27,13 @@ REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
class _PublishResult:
rc = 0
mid = 17
def is_published(self) -> bool:
return True
def wait_for_publish(self, timeout: float) -> bool:
return timeout > 0
raise AssertionError("control publish must never block for acknowledgement")
class _FakeMqttClient:
@@ -571,11 +575,14 @@ class MqttCanTransportTests(unittest.TestCase):
self.assertEqual(payload["data"], "10 00 00 00 00 00 00 00")
self.assertEqual(payload["repeat"], 1)
self.assertEqual(payload["intervalMs"], 0)
self.assertFalse(payload["expectResponse"])
self.assertEqual(payload["responseTimeoutMs"], 100)
publication = bus.published_messages[0]
self.assertEqual(publication["topic"], topic)
self.assertEqual(publication["qos"], qos)
self.assertEqual(publication["retain"], retain)
self.assertEqual(publication["delivery_status"], "published")
self.assertEqual(publication["message_id"], 17)
self.assertEqual(publication["payload"], payload)
self.assertTrue(
{
@@ -606,6 +613,40 @@ class MqttCanTransportTests(unittest.TestCase):
self.assertEqual(len(self.client.published), 2)
self.assertEqual(len(bus.published_messages), 2)
def test_publish_returns_queued_without_waiting_for_broker_ack(self) -> None:
class QueuedPublishResult:
rc = 0
mid = 29
@staticmethod
def is_published() -> bool:
return False
@staticmethod
def wait_for_publish(timeout: float) -> bool:
raise AssertionError(
f"wait_for_publish({timeout}) must not be called"
)
with patch.object(
self.client,
"publish",
return_value=QueuedPublishResult(),
):
publication = self.transport.publish(
CanFrame(
arbitration_id=0x431,
data=bytes.fromhex("10 00 00 00 00 00 00 00"),
channel=1,
is_fd=True,
bitrate_switch=True,
),
command="shade position 20",
)
self.assertEqual(publication["delivery_status"], "queued")
self.assertEqual(publication["message_id"], 29)
def test_can1_whitelist_publishes_rear_lock_candidate_with_debug_command(self) -> None:
bus = self.transport.bus(channel=1)
bus.set_debug_command("door rear lock")
@@ -717,6 +758,96 @@ class MqttCanTransportTests(unittest.TestCase):
)
)
def test_wireless_fixed_controls_and_shade_preset_never_wait_for_state(
self,
) -> None:
service = SeatDemoService(REPOSITORY_ROOT)
service._vehicle_mqtt_transport = self.transport
service.vehicle_mqtt_settings = SimpleNamespace(feedback_timeout_ms=1_500)
runtime = NativeCanRuntime(REPOSITORY_ROOT)
actions_by_domain = {
"climate": [
{
"domain": "climate",
"capability": "climate.set_temperature",
"target": "driver",
"operation": "set",
"parameters": {"temperature_c": 24},
},
{
"domain": "climate",
"capability": "climate.set_fan_level",
"target": "vehicle",
"operation": "set",
"parameters": {"level": 2},
},
],
"roof": [
{
"domain": "roof",
"capability": "roof.set_shade_motion",
"target": "vehicle",
"operation": "set",
"parameters": {"motion": "open"},
}
],
"ambient_light": [
{
"domain": "ambient_light",
"capability": "ambient_light.set_power",
"target": "vehicle",
"operation": "set",
"parameters": {"state": "on"},
}
],
}
configs = {
f"native_{domain}": runtime.build_function_config(
domain,
actions,
f"req-wireless-no-wait-{domain}",
"local_rules",
)
for domain, actions in actions_by_domain.items()
}
plans = {key: runtime.compile(config) for key, config in configs.items()}
with (
patch.object(
self.transport,
"_receive_after",
side_effect=AssertionError(
"wireless fixed commands must not wait on can/state"
),
) as receive_after,
patch("cognitive_agent.application.native_can_runtime.time.sleep"),
):
reports = service._execute_orchestrated_reports(
configs,
plans,
set(configs),
{},
hardware_request=True,
simulated_hardware_request=False,
simulation_fault="normal",
control_transport="wireless_mqtt",
)
receive_after.assert_not_called()
self.assertEqual(len(self.client.published), 10)
self.assertTrue(
all(
report["spec"]["status"] == "succeeded"
for report in reports.values()
)
)
self.assertTrue(
all(
json.loads(payload)["expectResponse"] is False
for _topic, payload, _qos, _retain in self.client.published
)
)
def test_model_added_nonwhitelisted_action_does_not_block_whitelisted_publish(
self,
) -> None:
@@ -261,6 +261,7 @@ def test_parsed_can_command_panel_combines_can0_seat_and_can1_vehicle_commands()
"capability": "seat.set_backrest_recline",
"target": {"zone": "front_right"},
"parameters": {"position_pct": 36, "angle_deg": 120},
"parse_source": "local_rule",
},
]
}
@@ -272,6 +273,7 @@ def test_parsed_can_command_panel_combines_can0_seat_and_can1_vehicle_commands()
"capability": "climate.set_temperature",
"target": {"zone": "driver"},
"parameters": {"temperature_c": 24},
"parse_source": "cloud_supplement",
}
]
}
@@ -300,11 +302,13 @@ def test_parsed_can_command_panel_combines_can0_seat_and_can1_vehicle_commands()
"category": "副驾座椅靠背调节",
"command": "seat passenger backrest position 36",
"frame": "0x6A0 · C7 <SEQ> 64 02 68 01 00 00",
"parseSource": "local_rule",
},
{
"channel": "CAN1",
"category": "空调温度",
"command": "ac temp driver 24",
"parseSource": "cloud_supplement",
},
]
+192
View File
@@ -0,0 +1,192 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from cognitive_agent.application.seat_service import SeatDemoService
ROOT = Path(__file__).resolve().parents[2]
def _service(tmp_path: Path) -> SeatDemoService:
service = SeatDemoService(ROOT)
service.artifact_root = tmp_path / "artifacts"
service.openai.api_key = "offline-two-stage-test-key"
return service
def _operations(result: dict) -> list[dict]:
return [
operation
for config in result.get("function_configs", {}).values()
for operation in config.get("spec", {}).get("operations", [])
]
def test_preview_validates_local_controls_without_calling_cloud(tmp_path: Path) -> None:
service = _service(tmp_path)
validated_events: list[dict] = []
with patch.object(
service.openai,
"interpret",
side_effect=AssertionError("preview must not call hosted parsing"),
) as interpret:
result = service.preview_native_can(
"空调温度设置为24度,风量设置为2档,播放音乐",
provider="openai",
conversation_id="conv-two-stage-local-preview",
execution_mode="dry_run",
on_commands_validated=validated_events.append,
)
interpret.assert_not_called()
assert result["phase"] == "deterministic_preview"
assert result["status"] == "succeeded"
assert result["supplement_required"] is True
assert result["unresolved_clauses"] == ["播放音乐"]
assert [item["capability"] for item in _operations(result)] == [
"climate.set_temperature",
"climate.set_fan_level",
]
assert {item["parse_source"] for item in _operations(result)} == {
"local_rule"
}
assert len(validated_events) == 1
assert validated_events[0]["phase"] == "deterministic_commands_validated"
assert validated_events[0]["status"] == "processing"
assert validated_events[0]["execution_report"] is None
assert _operations(validated_events[0]) == _operations(result)
assert validated_events[0]["command_plans"]
def test_fully_local_cabin_request_streams_all_commands_without_supplement(
tmp_path: Path,
) -> None:
service = _service(tmp_path)
validated_events: list[dict] = []
with patch.object(
service.openai,
"interpret",
side_effect=AssertionError("fully local request must not call GPT"),
):
result = service.preview_native_can(
"空调24度、风量2档,遮阳帘打开30%,打开氛围灯",
provider="openai",
conversation_id="conv-two-stage-all-local",
execution_mode="dry_run",
on_commands_validated=validated_events.append,
)
operations = _operations(result)
assert result["supplement_required"] is False
assert [item["capability"] for item in operations] == [
"climate.set_temperature",
"climate.set_fan_level",
"roof.set_shade_position",
"ambient_light.set_power",
]
assert operations[2]["parameters"]["position_pct"] == 30
assert {item["parse_source"] for item in operations} == {"local_rule"}
assert len(validated_events) == 1
assert _operations(validated_events[0]) == operations
def test_supplement_receives_only_unresolved_text_and_deduplicates_local_control(
tmp_path: Path,
) -> None:
service = _service(tmp_path)
preview = service.preview_native_can(
"观影时空调温度设置为24度并播放音乐",
provider="openai",
conversation_id="conv-two-stage-deduplicate",
execution_mode="dry_run",
)
assert [item["capability"] for item in _operations(preview)] == [
"climate.set_temperature"
]
model_output = {
"detected_domains": ["climate", "ambient_light"],
"requests": [
{
"domain": "climate",
"capability": "climate.set_temperature",
"target_scope": "driver",
"operation": "set",
"duration_ms": None,
"parameters": [{"name": "temperature_c", "value": 24}],
"reason": "模型重复给出了本地温度动作。",
},
{
"domain": "ambient_light",
"capability": "ambient_light.set_power",
"target_scope": "vehicle",
"operation": "set",
"duration_ms": None,
"parameters": [{"name": "state", "value": "on"}],
"reason": "模型补充观影氛围灯。",
},
],
"ambiguities": [],
"decision_summary": ["仅补充本地规则未覆盖的观影含义。"],
"confidence": 0.9,
}
with patch.object(
service.openai,
"interpret",
return_value=model_output,
) as interpret:
result = service.supplement_native_can(
preview["request_id"],
provider="openai",
)
assert interpret.call_args.args[0] == "观影时空调温度设置为24度并播放音乐"
operations = _operations(result)
assert [item["capability"] for item in operations].count(
"climate.set_temperature"
) == 1
assert [item["capability"] for item in operations].count(
"ambient_light.set_power"
) == 1
assert {
item["capability"]: item["parse_source"] for item in operations
} == {
"climate.set_temperature": "local_rule",
"ambient_light.set_power": "cloud_supplement",
}
cloud_results = (
result["phase_execution_reports"]["cloud_supplement"] or {}
).get("spec", {}).get("results", [])
assert len(cloud_results) == 1
def test_frontend_two_stage_contract_and_source_badges() -> None:
app = (ROOT / "apps" / "web-console" / "app.js").read_text(
encoding="utf-8"
)
console = (ROOT / "apps" / "web-console" / "console-v2.js").read_text(
encoding="utf-8"
)
styles = (ROOT / "apps" / "web-console" / "console-v2.css").read_text(
encoding="utf-8"
)
server = (ROOT / "src" / "cognitive_agent" / "server.py").read_text(
encoding="utf-8"
)
assert '"/api/requests/preview"' in app
assert "`/api/requests/${preview.request_id}/supplement`" in app
assert "renderResult(preview);" in app
assert 'Accept: "application/x-ndjson"' in app
assert '"cognitive-agent:validated-can-commands"' in app
assert "本地规则命令已执行;云端补充失败" in app
assert 'class="v2-can-command-source"' in console
assert 'item.parseSource === "local_rule" ? "本地规则" : "云端模型"' in console
assert '"cognitive-agent:validated-can-commands"' in console
assert '.v2-can-command-source[data-parse-source="local_rule"]' in styles
assert 'path not in {"/api/requests", "/api/requests/preview"}' in server
assert '"type": "validated_commands"' in server