优化后端和前端
This commit is contained in:
+139
-28
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 条有效命令,并验证越界、非整数、非法枚举和超出风口标定范围的指令不能通过白名单门禁。
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
+210
-21
@@ -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_client_id = (
|
||||
str(payload["hardware_client_id"])
|
||||
if payload.get("hardware_client_id")
|
||||
else None
|
||||
)
|
||||
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
|
||||
),
|
||||
"execution_mode": 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=(
|
||||
# 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=(
|
||||
str(payload["hardware_client_id"])
|
||||
if payload.get("hardware_client_id")
|
||||
else None
|
||||
"simulation_fault": str(
|
||||
payload.get("simulation_fault", "normal")
|
||||
),
|
||||
hardware_access_bypass=self._is_unproxied_loopback_request(),
|
||||
reasoning_effort=(
|
||||
str(payload["reasoning_effort"])
|
||||
if payload.get("reasoning_effort") is not None
|
||||
else None
|
||||
),
|
||||
control_transport=str(
|
||||
"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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
("打开前排座椅加热", "打开后排座椅加热", "打开所有座椅加热")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user