优化语音输入/普通文本输入

This commit is contained in:
minghanchen
2026-09-03 13:36:40 +08:00
parent 009d8ca73e
commit 32d179143f
26 changed files with 2318 additions and 53 deletions
+10 -1
View File
@@ -416,6 +416,15 @@
return [...unique.values()]; return [...unique.values()];
} }
function parsedCommandSourceLabel(source) {
return {
direct_control: "设备控件",
saved_mode_command_flow: "模式卡片",
local_rule: "本地规则",
speech_semantic: "语音语义",
}[source] || "云端模型";
}
function renderParsedCanCommands(result, { message = "" } = {}) { function renderParsedCanCommands(result, { message = "" } = {}) {
const panel = $("#v2CanCommandPanel"); const panel = $("#v2CanCommandPanel");
const list = $("#v2CanCommandList"); const list = $("#v2CanCommandList");
@@ -429,7 +438,7 @@
<li data-can-channel="${escapeHtml(item.channel)}" data-delivery-state="${escapeHtml(item.deliveryState)}"> <li data-can-channel="${escapeHtml(item.channel)}" data-delivery-state="${escapeHtml(item.deliveryState)}">
<span> <span>
<b class="v2-can-command-channel">${escapeHtml(item.channel)}</b> <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 === "direct_control" ? "设备控件" : item.parseSource === "saved_mode_command_flow" ? "模式卡片" : item.parseSource === "local_rule" ? "本地规则" : "云端模型"}</b>` : ""} ${item.parseSource ? `<b class="v2-can-command-source" data-parse-source="${escapeHtml(item.parseSource)}">${parsedCommandSourceLabel(item.parseSource)}</b>` : ""}
<b class="v2-can-command-delivery" data-delivery-state="${escapeHtml(item.deliveryState)}" title="${escapeHtml(`${item.deliveryTransport} · ${item.deliveryLabel}`)}">${escapeHtml(item.deliveryLabel)}</b> <b class="v2-can-command-delivery" data-delivery-state="${escapeHtml(item.deliveryState)}" title="${escapeHtml(`${item.deliveryTransport} · ${item.deliveryLabel}`)}">${escapeHtml(item.deliveryLabel)}</b>
<i class="v2-can-command-category">${escapeHtml(item.category)}</i> <i class="v2-can-command-category">${escapeHtml(item.category)}</i>
</span> </span>
+2 -2
View File
@@ -19,10 +19,10 @@
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script> <script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
<script src="/network.js?v=android-pad-2" defer></script> <script src="/network.js?v=android-pad-2" defer></script>
<script src="/app.js?v=dual-seat-114" defer></script> <script src="/app.js?v=dual-seat-114" defer></script>
<script src="/speech-input.js?v=sensevoice-6" defer></script> <script src="/speech-input.js?v=sensevoice-7" defer></script>
<script type="module" src="/gesture-control.js?v=android-pad-9"></script> <script type="module" src="/gesture-control.js?v=android-pad-9"></script>
<script src="/pwa.js?v=android-pad-3" defer></script> <script src="/pwa.js?v=android-pad-3" defer></script>
<script src="/console-v2.js?v=delivery-state-54" defer></script> <script src="/console-v2.js?v=delivery-state-55" defer></script>
<script src="/self-extension.js?v=gscu-tcp-41" defer></script> <script src="/self-extension.js?v=gscu-tcp-41" defer></script>
</head> </head>
<body class="console-v2"> <body class="console-v2">
+3 -3
View File
@@ -1,4 +1,4 @@
const CACHE_VERSION = "cognitive-agent-pad-v227"; const CACHE_VERSION = "cognitive-agent-pad-v228";
const SHELL_CACHE = `${CACHE_VERSION}-shell`; const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`; const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [ const APP_SHELL = [
@@ -10,11 +10,11 @@ const APP_SHELL = [
"/gesture-control.js?v=android-pad-9", "/gesture-control.js?v=android-pad-9",
"/pwa.js?v=android-pad-3", "/pwa.js?v=android-pad-3",
"/console-v2.css?v=delivery-state-50", "/console-v2.css?v=delivery-state-50",
"/console-v2.js?v=delivery-state-54", "/console-v2.js?v=delivery-state-55",
"/self-extension.css?v=gscu-tcp-42", "/self-extension.css?v=gscu-tcp-42",
"/self-extension.js?v=gscu-tcp-41", "/self-extension.js?v=gscu-tcp-41",
"/speech-input.css?v=sensevoice-5", "/speech-input.css?v=sensevoice-5",
"/speech-input.js?v=sensevoice-6", "/speech-input.js?v=sensevoice-7",
"/speech-audio-worklet.js?v=sensevoice-4", "/speech-audio-worklet.js?v=sensevoice-4",
"/assets/pets/self-extension-cat.png?v=3", "/assets/pets/self-extension-cat.png?v=3",
"/assets/pets/self-extension-cat-entrance.png?v=3", "/assets/pets/self-extension-cat-entrance.png?v=3",
+13 -3
View File
@@ -380,15 +380,21 @@
const { input } = elements(); const { input } = elements();
if (!input) throw new Error("没有找到一句话编排输入框"); if (!input) throw new Error("没有找到一句话编排输入框");
const cleanText = String(result.clean_text || result.text || "").trim(); const cleanText = String(result.clean_text || result.text || "").trim();
const peripheralEligible = result.peripheral_routing?.execution_eligible === true;
const inputText = peripheralEligible
? String(result.routing_text || result.raw_text || cleanText).trim()
: cleanText;
const commands = Array.isArray(result.commands) ? result.commands : []; const commands = Array.isArray(result.commands) ? result.commands : [];
if (!cleanText || commands.length === 0) { if (!inputText || (commands.length === 0 && !peripheralEligible)) {
window.dispatchEvent(new CustomEvent("cognitive-agent:speech-result", { detail: result })); window.dispatchEvent(new CustomEvent("cognitive-agent:speech-result", { detail: result }));
return false; return false;
} }
speech.applyingResult = true; speech.applyingResult = true;
input.value = cleanText.slice(0, input.maxLength || 1000); input.value = inputText.slice(0, input.maxLength || 1000);
input.dataset.speechUncertain = String(result.uncertain === true); input.dataset.speechUncertain = String(result.uncertain === true);
input.dataset.speechExecutionEligible = String(result.execution_eligible === true); input.dataset.speechExecutionEligible = String(
result.execution_eligible === true || peripheralEligible,
);
input.dataset.speechSemanticStatus = String(result.semantic_status || ""); input.dataset.speechSemanticStatus = String(result.semantic_status || "");
input.dataset.speechValue = input.value.trim(); input.dataset.speechValue = input.value.trim();
input.dataset.speechUtteranceId = String(result.utterance_id || ""); input.dataset.speechUtteranceId = String(result.utterance_id || "");
@@ -423,6 +429,10 @@
if (!applied) { if (!applied) {
setPhase("success", `未形成可执行控制指令 · ${latency} ms`); setPhase("success", `未形成可执行控制指令 · ${latency} ms`);
announce("已保留原始转录,但没有激活控制指令"); announce("已保留原始转录,但没有激活控制指令");
} else if (result.peripheral_routing?.execution_eligible === true) {
const count = Number(result.peripheral_routing.action_count || 1);
setPhase("success", `已识别为 ${count} 条外设控制指令 · ${latency} ms`);
announce("语音已匹配当前 NFC 外设的已学习控制指令");
} else if (result.semantic_status === "recognized_unsupported") { } else if (result.semantic_status === "recognized_unsupported") {
setPhase("success", `已识别,但当前车辆能力不支持 · ${latency} ms`); setPhase("success", `已识别,但当前车辆能力不支持 · ${latency} ms`);
announce("已完整保留用户语义,但当前车辆能力不支持执行"); announce("已完整保留用户语义,但当前车辆能力不支持执行");
@@ -0,0 +1,30 @@
# 设备能力卡片实验 Demo
这是一个完全独立的静态实验,不导入 Cognitive Agent 的现有前端或后端模块,
也不会调用 API、执行器或 CAN 通道。
## 运行
```bash
cd experiments/device-capability-card-demo
python3 -m http.server 8877 --bind 127.0.0.1
```
浏览器访问:<http://127.0.0.1:8877/>
## 测试
```bash
cd experiments/device-capability-card-demo
npm test
```
## 演示内容
- 实时修改设备名称、ID、领域、别名、目标范围和执行通道;
- 启用或停用能力,修改各项功能的自然语言说法;
- 根据能力定义自动生成设备功能卡片;
- 将自然语言转换为结构化控制命令;
- 展示设备匹配、功能匹配、范围解析、参数校验和执行器绑定轨迹;
- 对数值越界、未命中设备、功能停用等情况执行失败关闭;
- 所有执行载荷均带有 `demo_only: true`。
@@ -0,0 +1,288 @@
import {
DEFAULT_DESCRIPTOR,
buildManualCommand,
cloneDescriptor,
parseInstruction,
splitAliases,
} from "./parser-engine.js";
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
let descriptor = cloneDescriptor();
let latestPayload = null;
const categoryLabels = {
vehicle_electrical: "车端电器",
seat: "座椅设备",
peripheral: "扩展外设",
};
const transportLabels = {
native_can: "原生 CAN",
domain_runtime: "领域执行器",
gscu_tcp: "红外网关",
mqtt: "MQTT",
};
const exampleTemplates = [
(name) => `开启${name}`,
(name) => `把${name}温度调到4度`,
(name) => `将${name}设为速冷模式`,
(name) => `关闭${name}`,
(name) => `把${name}调到零下10度`,
];
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function fillIdentityForm() {
$("#deviceName").value = descriptor.device.name;
$("#deviceId").value = descriptor.device.id;
$("#deviceDomain").value = descriptor.device.domain;
$("#deviceCategory").value = descriptor.device.category;
$("#deviceAliases").value = descriptor.device.aliases.join("、");
$("#deviceScope").value = descriptor.device.defaultScope;
$("#deviceTransport").value = descriptor.device.transport;
}
function syncIdentityFromForm() {
descriptor.device.name = $("#deviceName").value.trim() || "未命名设备";
descriptor.device.id = $("#deviceId").value.trim() || "unnamed_device";
descriptor.device.domain = $("#deviceDomain").value.trim() || "custom";
descriptor.device.category = $("#deviceCategory").value;
descriptor.device.aliases = splitAliases($("#deviceAliases").value);
descriptor.device.defaultScope = $("#deviceScope").value;
descriptor.device.targetScopes = [descriptor.device.defaultScope];
descriptor.device.transport = $("#deviceTransport").value;
descriptor.device.targetDevice = descriptor.device.id;
renderAll({ preserveResult: true });
}
function renderCapabilityEditor() {
$("#capabilityEditor").innerHTML = descriptor.capabilities.map((capability, capabilityIndex) => `
<section class="capability-definition" data-capability-index="${capabilityIndex}">
<div class="capability-title">
<div>
<strong>${escapeHtml(capability.label)}</strong>
<code>${escapeHtml(capability.id)}</code>
</div>
<label class="toggle-label">
<input type="checkbox" data-capability-enabled ${capability.enabled !== false ? "checked" : ""} />
启用
</label>
</div>
<div class="intent-list">
${capability.intents.map((intent, intentIndex) => `
<label class="intent-alias-field">
<span>${escapeHtml(intent.label)}</span>
<input
data-intent-index="${intentIndex}"
value="${escapeHtml(intent.phrases.join("、"))}"
aria-label="${escapeHtml(capability.label + intent.label)}的自然语言说法"
/>
</label>
`).join("")}
</div>
</section>
`).join("");
}
function renderExamples() {
$("#exampleCommands").innerHTML = exampleTemplates.map((makeExample) => {
const utterance = makeExample(descriptor.device.name);
return `<button type="button" data-example="${escapeHtml(utterance)}">${escapeHtml(utterance)}</button>`;
}).join("");
}
function controlMarkup(capability) {
const ui = capability.ui;
if (ui.type === "select") {
return `
<label class="card-control">
<span>${escapeHtml(capability.label)}</span>
<select data-manual-capability="${escapeHtml(capability.id)}">
${ui.options.map(([value, label]) => `<option value="${escapeHtml(value)}"${String(value) === String(ui.value) ? " selected" : ""}>${escapeHtml(label)}</option>`).join("")}
</select>
</label>
`;
}
return `
<label class="card-control">
<span>${escapeHtml(capability.label)} <output>${escapeHtml(ui.value)}${escapeHtml(ui.unit || "")}</output></span>
<input
type="range"
min="${ui.minimum}"
max="${ui.maximum}"
step="${ui.step}"
value="${ui.value}"
data-manual-capability="${escapeHtml(capability.id)}"
data-unit="${escapeHtml(ui.unit || "")}"
/>
</label>
`;
}
function renderDeviceCard() {
const enabledCapabilities = descriptor.capabilities.filter((item) => item.enabled !== false);
const category = categoryLabels[descriptor.device.category] || "自定义设备";
const transport = transportLabels[descriptor.device.transport] || descriptor.device.transport;
const icon = descriptor.device.category === "seat" ? "◫" : descriptor.device.category === "peripheral" ? "⌁" : "❄";
$("#deviceCardPreview").innerHTML = `
<header class="device-card-header">
<div class="device-identity">
<span class="device-icon" aria-hidden="true">${icon}</span>
<div>
<h3>${escapeHtml(descriptor.device.name)}</h3>
<p>${escapeHtml(category)} · ${escapeHtml(transport)} · ${escapeHtml(descriptor.device.defaultScope)}</p>
</div>
</div>
<span class="connection-state"><i></i>实验就绪</span>
</header>
<div class="readout-grid">
${descriptor.readouts.map((readout) => `<div><span>${escapeHtml(readout.label)}</span><strong>${escapeHtml(readout.value)}</strong></div>`).join("")}
</div>
<div class="card-controls">
${enabledCapabilities.length ? enabledCapabilities.map(controlMarkup).join("") : "<p>没有已启用功能。</p>"}
</div>
<p class="card-footnote">功能控件只生成结构化演示数据;执行映射为虚拟引用,不连接现有服务。</p>
`;
}
function compactDescriptor() {
return {
version: descriptor.version,
device: descriptor.device,
capabilities: descriptor.capabilities.map((capability) => ({
id: capability.id,
property: capability.property,
target_scopes: descriptor.device.targetScopes,
parameters: capability.parameters,
intents: capability.intents,
risk_level: capability.risk,
confirmation_policy: capability.confirmationPolicy,
executor: capability.executor,
enabled: capability.enabled,
})),
};
}
function renderDescriptor() {
$("#descriptorPreview").textContent = JSON.stringify(compactDescriptor(), null, 2);
}
function renderAll({ preserveResult = false } = {}) {
renderCapabilityEditor();
renderExamples();
renderDeviceCard();
renderDescriptor();
if (!preserveResult) renderResult(null);
}
function resultPresentation(result) {
if (!result) return { status: "idle", title: "等待解析", message: "输入一句话,查看设备、功能和参数的匹配过程。" };
if (result.source === "card_control") {
return {
status: "success",
title: "已生成卡片控制载荷",
message: `已生成 ${result.commands.length} 条结构化演示指令;实验页面不会发送到车辆。`,
};
}
if (result.semantic_status === "recognized_executable") {
return { status: "success", title: "已匹配可执行功能", message: `${result.clean_text} 当前仅生成演示载荷。` };
}
if (result.semantic_status === "ambiguous" || result.semantic_status === "incomplete") {
return { status: "warning", title: "需要补充信息", message: result.issues.join(";") };
}
return { status: "error", title: "未生成控制指令", message: result.issues.join(";") || "没有匹配到设备能力。" };
}
function renderResult(result) {
const presentation = resultPresentation(result);
$("#resultBanner").dataset.status = presentation.status;
$("#resultTitle").textContent = presentation.title;
$("#resultMessage").textContent = presentation.message;
$("#traceList").innerHTML = result?.trace?.length
? result.trace.map((item) => `<li data-state="${escapeHtml(item.state)}"><strong>${escapeHtml(item.stage)}</strong>${escapeHtml(item.detail)}</li>`).join("")
: "<li><strong>尚无轨迹</strong>解析后将在这里显示每一步决策。</li>";
latestPayload = result;
$("#payloadOutput").textContent = JSON.stringify(result || {}, null, 2);
$("#copyPayload").disabled = !result;
}
function parseCurrentUtterance() {
renderResult(parseInstruction($("#utterance").value, descriptor));
}
function bindEvents() {
["deviceName", "deviceId", "deviceDomain", "deviceCategory", "deviceAliases", "deviceScope", "deviceTransport"]
.forEach((id) => $("#" + id).addEventListener("input", syncIdentityFromForm));
$("#capabilityEditor").addEventListener("input", (event) => {
const section = event.target.closest("[data-capability-index]");
if (!section) return;
const capability = descriptor.capabilities[Number(section.dataset.capabilityIndex)];
if (event.target.matches("[data-capability-enabled]")) {
capability.enabled = event.target.checked;
} else if (event.target.matches("[data-intent-index]")) {
capability.intents[Number(event.target.dataset.intentIndex)].phrases = splitAliases(event.target.value);
}
renderDeviceCard();
renderDescriptor();
});
$("#exampleCommands").addEventListener("click", (event) => {
const button = event.target.closest("[data-example]");
if (!button) return;
$("#utterance").value = button.dataset.example;
parseCurrentUtterance();
});
$("#parseCommand").addEventListener("click", parseCurrentUtterance);
$("#utterance").addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") parseCurrentUtterance();
});
$("#deviceCardPreview").addEventListener("input", (event) => {
if (!event.target.matches("[data-manual-capability]")) return;
const output = event.target.closest(".card-control")?.querySelector("output");
if (output) output.textContent = `${event.target.value}${event.target.dataset.unit || ""}`;
const values = Object.fromEntries($$("[data-manual-capability]", $("#deviceCardPreview"))
.map((input) => [input.dataset.manualCapability, input.value]));
const result = {
source: "card_control",
semantic_status: "recognized_executable",
execution_eligible: false,
note: "实验页面不允许真实执行",
commands: buildManualCommand(descriptor, values),
};
renderResult(result);
});
$("#copyPayload").addEventListener("click", async () => {
if (!latestPayload) return;
await navigator.clipboard.writeText(JSON.stringify(latestPayload, null, 2));
const button = $("#copyPayload");
button.textContent = "已复制";
window.setTimeout(() => { button.textContent = "复制"; }, 1200);
});
$("#resetDemo").addEventListener("click", () => {
descriptor = cloneDescriptor(DEFAULT_DESCRIPTOR);
fillIdentityForm();
$("#utterance").value = "把冰箱温度调到4度";
renderAll();
parseCurrentUtterance();
});
}
fillIdentityForm();
renderAll();
bindEvents();
parseCurrentUtterance();
@@ -0,0 +1,149 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>设备能力卡片实验室</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<main class="demo-shell">
<header class="page-header">
<div>
<p class="eyebrow">独立实验 · 不连接车辆</p>
<h1>设备能力卡片实验室</h1>
<p class="page-intro">修改设备名称、别名和功能说法,观察自然语言如何匹配为结构化控制指令。</p>
</div>
<button class="secondary-button" id="resetDemo" type="button">恢复示例</button>
</header>
<section class="safety-note" aria-label="实验边界">
<span aria-hidden="true">◇</span>
<p><strong>安全边界</strong> 本页面只在浏览器内生成演示数据,不导入当前项目模块,也不会调用控制接口或发送 CAN 指令。</p>
</section>
<div class="workspace-grid">
<aside class="configuration-panel" aria-labelledby="configurationTitle">
<div class="section-heading">
<span>01</span>
<h2 id="configurationTitle">设备定义</h2>
</div>
<div class="field-grid">
<label class="field">
<span>设备名称</span>
<input id="deviceName" autocomplete="off" />
</label>
<label class="field">
<span>设备 ID</span>
<input id="deviceId" autocomplete="off" spellcheck="false" />
</label>
<label class="field">
<span>所属领域</span>
<input id="deviceDomain" autocomplete="off" spellcheck="false" />
</label>
<label class="field">
<span>设备类型</span>
<select id="deviceCategory">
<option value="vehicle_electrical">车端电器</option>
<option value="seat">座椅设备</option>
<option value="peripheral">扩展外设</option>
</select>
</label>
<label class="field field-wide">
<span>设备别名</span>
<input id="deviceAliases" autocomplete="off" placeholder="使用顿号或逗号分隔" />
<small>解析时优先匹配最长别名;避免使用“灯、开关、设备”等过宽名称。</small>
</label>
<label class="field">
<span>默认目标</span>
<select id="deviceScope">
<option value="vehicle">全车</option>
<option value="driver">主驾</option>
<option value="passenger">副驾</option>
<option value="rear_row">后排</option>
</select>
</label>
<label class="field">
<span>执行通道</span>
<select id="deviceTransport">
<option value="native_can">原生 CAN</option>
<option value="domain_runtime">领域执行器</option>
<option value="gscu_tcp">红外网关</option>
<option value="mqtt">MQTT</option>
</select>
</label>
</div>
<div class="section-heading compact-heading">
<span>02</span>
<h2>功能定义</h2>
</div>
<div id="capabilityEditor" class="capability-editor"></div>
<details class="schema-details">
<summary>查看卡片声明</summary>
<pre id="descriptorPreview" aria-label="设备卡片声明"></pre>
</details>
</aside>
<section class="experiment-panel">
<div class="pipeline" aria-label="匹配流程">
<span>设备别名</span><i aria-hidden="true">→</i>
<span>功能说法</span><i aria-hidden="true">→</i>
<span>参数校验</span><i aria-hidden="true">→</i>
<span>执行载荷</span>
</div>
<section class="preview-section" aria-labelledby="previewTitle">
<div class="section-heading">
<span>03</span>
<h2 id="previewTitle">功能卡片</h2>
</div>
<article class="device-card" id="deviceCardPreview"></article>
</section>
<section class="parser-section" aria-labelledby="parserTitle">
<div class="section-heading">
<span>04</span>
<h2 id="parserTitle">指令解析</h2>
</div>
<div class="example-row" id="exampleCommands" aria-label="示例指令"></div>
<label class="command-composer">
<span class="sr-only">自然语言控制指令</span>
<textarea id="utterance" rows="2" maxlength="180">把冰箱温度调到4度</textarea>
<button class="primary-button" id="parseCommand" type="button">解析指令</button>
</label>
<div class="result-banner" id="resultBanner" data-status="idle" aria-live="polite">
<span class="result-dot" aria-hidden="true"></span>
<div>
<strong id="resultTitle">等待解析</strong>
<p id="resultMessage">输入一句话,查看设备、功能和参数的匹配过程。</p>
</div>
</div>
<div class="result-grid">
<section class="trace-panel" aria-labelledby="traceTitle">
<h3 id="traceTitle">匹配轨迹</h3>
<ol id="traceList"></ol>
</section>
<section class="payload-panel" aria-labelledby="payloadTitle">
<div class="payload-heading">
<h3 id="payloadTitle">结构化结果</h3>
<button id="copyPayload" class="text-button" type="button" disabled>复制</button>
</div>
<pre id="payloadOutput">{}</pre>
</section>
</div>
</section>
</section>
</div>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>
@@ -0,0 +1,8 @@
{
"name": "device-capability-card-demo",
"private": true,
"type": "module",
"scripts": {
"test": "node parser-engine.test.mjs"
}
}
@@ -0,0 +1,353 @@
export const DEFAULT_DESCRIPTOR = {
version: 1,
device: {
id: "cabin_refrigerator",
name: "车载冰箱",
aliases: ["冰箱", "车载冷藏箱", "冷藏箱"],
domain: "refrigerator",
category: "vehicle_electrical",
targetDevice: "refrigerator",
targetScopes: ["vehicle"],
defaultScope: "vehicle",
transport: "native_can",
channel: "实验通道",
},
readouts: [
{ key: "power", label: "工作状态", value: "制冷中" },
{ key: "temperature", label: "当前温度", value: "6℃" },
{ key: "mode", label: "当前模式", value: "节能" },
],
capabilities: [
{
id: "refrigerator.set_power",
label: "工作状态",
property: "refrigerator.power",
operation: "set",
enabled: true,
risk: "low",
confirmationPolicy: "on_ambiguity",
parameters: [
{ name: "state", type: "string", required: true, allowedValues: ["on", "off"] },
],
intents: [
{ label: "开启", phrases: ["打开", "开启", "启动", "开启制冷"], values: { state: "on" } },
{ label: "关闭", phrases: ["关闭", "关掉", "停止", "停止制冷"], values: { state: "off" } },
],
ui: {
type: "select",
value: "on",
options: [["off", "关闭"], ["on", "开启"]],
},
executor: { binding: "NativeCanRuntime", mappingRef: "demo.refrigerator.power" },
},
{
id: "refrigerator.set_temperature",
label: "目标温度",
property: "refrigerator.temperature",
operation: "set",
enabled: true,
risk: "medium",
confirmationPolicy: "on_ambiguity",
parameters: [
{ name: "temperature_c", type: "number", unit: "celsius", required: true, minimum: 0, maximum: 15 },
],
intents: [
{
label: "设置温度",
phrases: ["温度", "调到", "设置为", "制冷到", "冷藏到"],
extract: { parameter: "temperature_c", kind: "number" },
},
],
ui: { type: "range", value: 4, minimum: 0, maximum: 15, step: 1, unit: "℃" },
executor: { binding: "NativeCanRuntime", mappingRef: "demo.refrigerator.temperature" },
},
{
id: "refrigerator.set_mode",
label: "工作模式",
property: "refrigerator.mode",
operation: "set",
enabled: true,
risk: "low",
confirmationPolicy: "on_ambiguity",
parameters: [
{ name: "mode", type: "string", required: true, allowedValues: ["eco", "fast_cool"] },
],
intents: [
{ label: "节能", phrases: ["节能模式", "省电模式", "节能"], values: { mode: "eco" } },
{ label: "速冷", phrases: ["速冷模式", "快速制冷", "速冷"], values: { mode: "fast_cool" } },
],
ui: {
type: "select",
value: "eco",
options: [["eco", "节能"], ["fast_cool", "速冷"]],
},
executor: { binding: "NativeCanRuntime", mappingRef: "demo.refrigerator.mode" },
},
],
};
const SCOPE_ALIASES = {
driver: ["主驾", "驾驶位", "司机位"],
passenger: ["副驾", "副驾驶", "乘客位"],
rear_row: ["后排", "第二排"],
vehicle: ["全车", "整车", "车内"],
};
const POLITE_WORDS = /^(?:请|麻烦|拜托|帮我|请帮我|给我)+/;
const END_POLITE_WORDS = /(?:谢谢|可以吗|好吗|行吗)[。!!??]*$/;
export function cloneDescriptor(value = DEFAULT_DESCRIPTOR) {
return JSON.parse(JSON.stringify(value));
}
export function splitAliases(value) {
return [...new Set(String(value || "")
.split(/[,,、;;|]/)
.map((item) => item.trim())
.filter(Boolean))];
}
export function normalizeText(value) {
return String(value || "")
.normalize("NFKC")
.trim()
.replace(POLITE_WORDS, "")
.replace(END_POLITE_WORDS, "")
.toLowerCase()
.replace(/[\s,,。;;!!??、]/g, "");
}
function longestContained(normalizedText, phrases) {
return [...new Set(phrases.map(normalizeText).filter(Boolean))]
.filter((phrase) => normalizedText.includes(phrase))
.sort((left, right) => right.length - left.length)[0] || "";
}
function chineseInteger(token) {
if (!token) return null;
if (/^\d+$/.test(token)) return Number(token);
const digits = { 零: 0, 〇: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9 };
if (token.includes("十")) {
const [left, right] = token.split("十", 2);
const tens = left ? digits[left.at(-1)] : 1;
const ones = right ? digits[right.at(-1)] : 0;
return Number.isFinite(tens) && Number.isFinite(ones) ? tens * 10 + ones : null;
}
if ([...token].every((char) => Object.hasOwn(digits, char))) {
return Number([...token].map((char) => digits[char]).join(""));
}
return null;
}
function extractNumber(text) {
const belowZero = String(text).match(/零下(\d+(?:\.\d+)?|[零〇一二两三四五六七八九十]+)/);
if (belowZero) {
const value = /^\d/.test(belowZero[1]) ? Number(belowZero[1]) : chineseInteger(belowZero[1]);
return value === null ? null : -value;
}
const numeric = String(text).match(/-?\d+(?:\.\d+)?/);
if (numeric) return Number(numeric[0]);
const chinese = String(text).match(/[零〇一二两三四五六七八九十]+/);
return chinese ? chineseInteger(chinese[0]) : null;
}
function resolveScope(normalizedText, device) {
const explicit = Object.entries(SCOPE_ALIASES)
.flatMap(([scope, aliases]) => aliases.map((alias) => ({ scope, alias: normalizeText(alias) })))
.find(({ alias }) => normalizedText.includes(alias));
if (explicit) {
if (!device.targetScopes.includes(explicit.scope)) {
return { status: "unsupported", scope: explicit.scope, explicit: true, reason: `设备不支持“${explicit.alias}”范围` };
}
return { status: "resolved", scope: explicit.scope, explicit: true, reason: `显式目标:${explicit.alias}` };
}
if (device.defaultScope && device.targetScopes.includes(device.defaultScope)) {
return { status: "resolved", scope: device.defaultScope, explicit: false, reason: `采用卡片默认目标:${device.defaultScope}` };
}
if (device.targetScopes.length === 1) {
return { status: "resolved", scope: device.targetScopes[0], explicit: false, reason: `能力仅支持唯一目标:${device.targetScopes[0]}` };
}
return { status: "ambiguous", scope: null, explicit: false, reason: "存在多个目标范围,需要用户确认" };
}
function validateParameters(capability, parameters) {
const issues = [];
for (const schema of capability.parameters || []) {
const value = parameters[schema.name];
if ((value === undefined || value === null || value === "") && schema.required) {
issues.push(`缺少必填参数 ${schema.name}`);
continue;
}
if (value === undefined || value === null || value === "") continue;
if (schema.type === "number" && typeof value !== "number") {
issues.push(`${schema.name} 必须是数字`);
}
if (schema.minimum !== undefined && value < schema.minimum) {
issues.push(`${schema.name}=${value} 低于下限 ${schema.minimum}`);
}
if (schema.maximum !== undefined && value > schema.maximum) {
issues.push(`${schema.name}=${value} 超过上限 ${schema.maximum}`);
}
if (schema.allowedValues && !schema.allowedValues.includes(value)) {
issues.push(`${schema.name}=${value} 不在允许值中`);
}
}
return issues;
}
function renderCleanText(descriptor, capability, parameters) {
const deviceName = descriptor.device.name;
if (capability.property.endsWith(".power")) {
return `${parameters.state === "on" ? "开启" : "关闭"}${deviceName}。`;
}
if (capability.property.endsWith(".temperature")) {
return `将${deviceName}设置为${parameters.temperature_c}度。`;
}
if (capability.property.endsWith(".mode")) {
const modeLabels = { eco: "节能模式", fast_cool: "速冷模式" };
return `将${deviceName}设置为${modeLabels[parameters.mode] || parameters.mode}。`;
}
return `执行${deviceName}的${capability.label}。`;
}
function baseResult(rawText, normalizedText, trace) {
return {
raw_text: rawText,
normalized_text: normalizedText,
semantic_status: "no_command",
execution_eligible: false,
clean_text: "",
command: null,
issues: [],
trace,
};
}
export function parseInstruction(rawText, descriptorInput = DEFAULT_DESCRIPTOR) {
const descriptor = cloneDescriptor(descriptorInput);
const normalizedText = normalizeText(rawText);
const trace = [{ stage: "文本整理", state: "success", detail: normalizedText || "输入为空" }];
const result = baseResult(String(rawText || ""), normalizedText, trace);
if (!normalizedText) {
result.semantic_status = "incomplete";
result.issues.push("请输入自然语言指令");
trace.push({ stage: "设备识别", state: "error", detail: "没有可分析的文字" });
return result;
}
const devicePhrases = [descriptor.device.name, ...(descriptor.device.aliases || [])];
const matchedDevice = longestContained(normalizedText, devicePhrases);
if (!matchedDevice) {
result.issues.push("指令没有命中设备名称或别名");
trace.push({ stage: "设备识别", state: "error", detail: `候选:${devicePhrases.join("、")}` });
return result;
}
trace.push({ stage: "设备识别", state: "success", detail: `“${matchedDevice}” → ${descriptor.device.id}` });
const candidates = [];
for (const capability of descriptor.capabilities.filter((item) => item.enabled !== false)) {
for (const intent of capability.intents || []) {
const phrase = longestContained(normalizedText, intent.phrases || []);
if (!phrase) continue;
const parameters = { ...(intent.values || {}) };
if (intent.extract?.kind === "number") {
const number = extractNumber(rawText);
if (number !== null) parameters[intent.extract.parameter] = number;
}
candidates.push({
capability,
intent,
phrase,
parameters,
score: matchedDevice.length * 100 + phrase.length,
});
}
}
if (!candidates.length) {
result.semantic_status = "incomplete";
result.issues.push("设备已识别,但没有命中已启用的功能说法");
trace.push({ stage: "功能识别", state: "error", detail: "请使用卡片中定义的功能说法" });
return result;
}
candidates.sort((left, right) => right.score - left.score);
const best = candidates[0];
const tied = candidates.filter((item) => item.score === best.score);
if (tied.length > 1) {
result.semantic_status = "ambiguous";
result.issues.push(`同时命中多个功能:${tied.map((item) => item.intent.label).join("、")}`);
trace.push({ stage: "功能识别", state: "error", detail: "最高匹配分相同,需要补充更明确的动作" });
return result;
}
trace.push({ stage: "功能识别", state: "success", detail: `“${best.phrase}” → ${best.capability.id}` });
const scope = resolveScope(normalizedText, descriptor.device);
trace.push({ stage: "目标解析", state: scope.status === "resolved" ? "success" : "error", detail: scope.reason });
if (scope.status !== "resolved") {
result.semantic_status = scope.status === "ambiguous" ? "ambiguous" : "recognized_unsupported";
result.issues.push(scope.reason);
return result;
}
const issues = validateParameters(best.capability, best.parameters);
trace.push({
stage: "参数校验",
state: issues.length ? "error" : "success",
detail: issues.length ? issues.join(";") : JSON.stringify(best.parameters),
});
if (issues.length) {
result.semantic_status = Object.keys(best.parameters).length ? "recognized_unsupported" : "incomplete";
result.issues.push(...issues);
return result;
}
result.semantic_status = "recognized_executable";
result.execution_eligible = true;
result.clean_text = renderCleanText(descriptor, best.capability, best.parameters);
result.command = {
domain: descriptor.device.domain,
capability: best.capability.id,
property: best.capability.property,
target_device: descriptor.device.targetDevice,
target_scope: scope.scope,
operation: best.capability.operation,
parameters: best.parameters,
risk_level: best.capability.risk,
confirmation_policy: best.capability.confirmationPolicy,
executor_binding: best.capability.executor.binding,
executor_payload: {
transport: descriptor.device.transport,
mapping_ref: best.capability.executor.mappingRef,
capability: best.capability.id,
target: scope.scope,
parameters: best.parameters,
demo_only: true,
},
};
trace.push({ stage: "执行绑定", state: "success", detail: `${best.capability.executor.binding} · 演示载荷,不发送` });
return result;
}
export function buildManualCommand(descriptorInput, values) {
const descriptor = cloneDescriptor(descriptorInput);
const commands = [];
for (const capability of descriptor.capabilities.filter((item) => item.enabled !== false)) {
const value = values[capability.id];
if (value === undefined) continue;
const schema = capability.parameters?.[0];
if (!schema) continue;
const parsedValue = schema.type === "number" ? Number(value) : value;
const parameters = { [schema.name]: parsedValue };
const issues = validateParameters(capability, parameters);
if (issues.length) continue;
commands.push({
capability: capability.id,
target_scope: descriptor.device.defaultScope,
parameters,
demo_only: true,
});
}
return commands;
}
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import { DEFAULT_DESCRIPTOR, cloneDescriptor, parseInstruction } from "./parser-engine.js";
const successCases = [
["开启车载冰箱", "refrigerator.set_power", { state: "on" }],
["把冰箱温度调到4度", "refrigerator.set_temperature", { temperature_c: 4 }],
["将冷藏箱设为速冷模式", "refrigerator.set_mode", { mode: "fast_cool" }],
["关闭冰箱", "refrigerator.set_power", { state: "off" }],
];
for (const [utterance, capability, parameters] of successCases) {
const result = parseInstruction(utterance, DEFAULT_DESCRIPTOR);
assert.equal(result.semantic_status, "recognized_executable", utterance);
assert.equal(result.execution_eligible, true, utterance);
assert.equal(result.command.capability, capability, utterance);
assert.deepEqual(result.command.parameters, parameters, utterance);
assert.equal(result.command.executor_payload.demo_only, true, utterance);
}
const outOfRange = parseInstruction("把冰箱调到零下10度", DEFAULT_DESCRIPTOR);
assert.equal(outOfRange.semantic_status, "recognized_unsupported");
assert.equal(outOfRange.execution_eligible, false);
assert.match(outOfRange.issues.join(" "), /低于下限/);
const noDevice = parseInstruction("把空调调到4度", DEFAULT_DESCRIPTOR);
assert.equal(noDevice.semantic_status, "no_command");
assert.equal(noDevice.execution_eligible, false);
const disabled = cloneDescriptor();
disabled.capabilities.find((item) => item.id === "refrigerator.set_temperature").enabled = false;
const disabledResult = parseInstruction("把冰箱温度调到4度", disabled);
assert.equal(disabledResult.semantic_status, "incomplete");
console.log(`device capability card demo: ${successCases.length + 3} parser checks passed`);
@@ -0,0 +1,746 @@
:root {
color-scheme: light dark;
--page: #f2f5f2;
--surface: #ffffff;
--surface-soft: #f7f9f7;
--surface-strong: #e8eeea;
--ink: #18201c;
--muted: #68736c;
--line: #d8dfda;
--accent: #176b4a;
--accent-soft: #dceee5;
--accent-ink: #0f5137;
--warning: #9a640b;
--warning-soft: #fff3d7;
--danger: #a33a3a;
--danger-soft: #fde9e7;
--shadow: 0 18px 55px rgba(32, 50, 40, 0.08);
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
background:
radial-gradient(circle at 8% 3%, rgba(23, 107, 74, 0.1), transparent 25rem),
var(--page);
color: var(--ink);
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
.demo-shell {
width: min(1440px, calc(100% - 40px));
margin: 0 auto;
padding: 38px 0 60px;
}
.page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
margin-bottom: 22px;
}
.eyebrow {
margin: 0 0 8px;
color: var(--accent);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.12em;
}
h1,
h2,
h3,
p {
margin-top: 0;
}
h1 {
margin-bottom: 10px;
font-size: clamp(30px, 4vw, 48px);
line-height: 1.05;
letter-spacing: -0.045em;
}
.page-intro {
max-width: 720px;
margin-bottom: 0;
color: var(--muted);
line-height: 1.7;
}
.secondary-button,
.primary-button {
min-height: 42px;
border-radius: 11px;
padding: 0 17px;
font-weight: 700;
}
.secondary-button {
border: 1px solid var(--line);
background: var(--surface);
color: var(--ink);
}
.primary-button {
border: 1px solid var(--accent);
background: var(--accent);
color: #fff;
}
.safety-note {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--line));
border-radius: 14px;
padding: 12px 16px;
background: color-mix(in srgb, var(--accent-soft) 60%, var(--surface));
color: var(--accent-ink);
}
.safety-note > span {
display: grid;
width: 28px;
height: 28px;
place-items: center;
border-radius: 50%;
background: var(--accent);
color: #fff;
}
.safety-note p {
margin: 0;
line-height: 1.55;
}
.workspace-grid {
display: grid;
grid-template-columns: minmax(320px, 0.75fr) minmax(560px, 1.55fr);
gap: 20px;
align-items: start;
}
.configuration-panel,
.preview-section,
.parser-section {
border: 1px solid var(--line);
border-radius: 20px;
background: var(--surface);
box-shadow: var(--shadow);
}
.configuration-panel {
padding: 22px;
}
.experiment-panel {
display: grid;
gap: 20px;
}
.preview-section,
.parser-section {
padding: 22px;
}
.section-heading {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
}
.section-heading > span {
display: grid;
width: 30px;
height: 30px;
place-items: center;
border-radius: 9px;
background: var(--accent-soft);
color: var(--accent-ink);
font-size: 12px;
font-weight: 800;
}
.section-heading h2 {
margin: 0;
font-size: 19px;
letter-spacing: -0.02em;
}
.compact-heading {
margin-top: 28px;
}
.field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.field {
display: grid;
gap: 7px;
}
.field-wide {
grid-column: 1 / -1;
}
.field > span,
.intent-alias-field > span {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.field small {
color: var(--muted);
font-size: 11px;
line-height: 1.5;
}
input,
select,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--surface-soft);
color: var(--ink);
outline: none;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
input,
select {
min-height: 40px;
padding: 0 11px;
}
textarea {
resize: vertical;
padding: 12px;
line-height: 1.6;
}
input:focus,
select:focus,
textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
}
.capability-editor {
display: grid;
gap: 12px;
}
.capability-definition {
border-top: 1px solid var(--line);
padding-top: 14px;
}
.capability-definition:first-child {
border-top: 0;
padding-top: 0;
}
.capability-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.capability-title strong {
font-size: 14px;
}
.capability-title code {
color: var(--muted);
font-size: 11px;
}
.toggle-label {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--muted);
font-size: 12px;
}
.toggle-label input {
width: 16px;
min-height: 16px;
accent-color: var(--accent);
}
.intent-list {
display: grid;
gap: 9px;
}
.intent-alias-field {
display: grid;
grid-template-columns: 72px 1fr;
align-items: center;
gap: 8px;
}
.intent-alias-field input {
min-height: 36px;
font-size: 12px;
}
.schema-details {
margin-top: 20px;
border-top: 1px solid var(--line);
padding-top: 16px;
}
.schema-details summary {
color: var(--accent);
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
pre {
overflow: auto;
margin: 12px 0 0;
border-radius: 12px;
padding: 15px;
background: #17231d;
color: #d8e6dd;
font: 12px/1.65 "SFMono-Regular", Consolas, monospace;
white-space: pre-wrap;
word-break: break-word;
}
.pipeline {
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
min-height: 54px;
padding: 10px 16px;
color: var(--muted);
font-size: 12px;
}
.pipeline span {
border: 1px solid var(--line);
border-radius: 999px;
padding: 7px 11px;
background: var(--surface);
color: var(--ink);
font-weight: 700;
}
.pipeline i {
color: var(--accent);
font-style: normal;
}
.device-card {
position: relative;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--accent) 16%, var(--line));
border-radius: 18px;
padding: 20px;
background:
linear-gradient(135deg, color-mix(in srgb, var(--accent-soft) 70%, var(--surface)), var(--surface) 52%);
}
.device-card::after {
position: absolute;
right: -50px;
top: -70px;
width: 180px;
height: 180px;
border-radius: 50%;
background: color-mix(in srgb, var(--accent) 9%, transparent);
content: "";
}
.device-card-header {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.device-identity {
display: flex;
align-items: center;
gap: 12px;
}
.device-icon {
display: grid;
width: 48px;
height: 48px;
place-items: center;
border-radius: 15px;
background: var(--accent);
color: #fff;
font-size: 22px;
}
.device-identity h3 {
margin: 0 0 4px;
font-size: 21px;
}
.device-identity p {
margin: 0;
color: var(--muted);
font-size: 12px;
}
.connection-state {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 12px;
font-weight: 700;
color: var(--accent-ink);
}
.connection-state i,
.result-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
}
.readout-grid {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin: 20px 0;
}
.readout-grid div {
border: 1px solid color-mix(in srgb, var(--line) 78%, transparent);
border-radius: 12px;
padding: 12px;
background: color-mix(in srgb, var(--surface) 84%, transparent);
}
.readout-grid span,
.card-control span {
display: block;
margin-bottom: 6px;
color: var(--muted);
font-size: 11px;
font-weight: 700;
}
.readout-grid strong {
font-size: 16px;
}
.card-controls {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.card-control input,
.card-control select {
background: color-mix(in srgb, var(--surface) 88%, transparent);
}
.card-control output {
float: right;
color: var(--accent);
font-size: 12px;
font-weight: 800;
}
.card-control input[type="range"] {
min-height: auto;
padding: 0;
accent-color: var(--accent);
}
.card-footnote {
position: relative;
z-index: 1;
margin: 15px 0 0;
color: var(--muted);
font-size: 11px;
}
.example-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.example-row button,
.text-button {
border: 0;
background: transparent;
color: var(--accent);
font-size: 12px;
font-weight: 700;
}
.example-row button {
border-radius: 999px;
padding: 7px 10px;
background: var(--accent-soft);
}
.command-composer {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: stretch;
}
.command-composer textarea {
min-height: 64px;
}
.result-banner {
display: flex;
align-items: center;
gap: 12px;
margin: 14px 0;
border-radius: 13px;
padding: 13px 15px;
background: var(--surface-soft);
}
.result-banner[data-status="success"] {
background: var(--accent-soft);
color: var(--accent-ink);
}
.result-banner[data-status="warning"] {
background: var(--warning-soft);
color: var(--warning);
}
.result-banner[data-status="error"] {
background: var(--danger-soft);
color: var(--danger);
}
.result-banner[data-status="warning"] .result-dot {
background: var(--warning);
}
.result-banner[data-status="error"] .result-dot {
background: var(--danger);
}
.result-banner strong {
display: block;
margin-bottom: 3px;
font-size: 14px;
}
.result-banner p {
margin: 0;
font-size: 12px;
line-height: 1.55;
}
.result-grid {
display: grid;
grid-template-columns: minmax(240px, 0.85fr) minmax(280px, 1.15fr);
gap: 14px;
}
.trace-panel,
.payload-panel {
min-width: 0;
border: 1px solid var(--line);
border-radius: 14px;
padding: 15px;
}
.trace-panel h3,
.payload-heading h3 {
margin: 0;
font-size: 14px;
}
.trace-panel ol {
display: grid;
gap: 12px;
margin: 15px 0 0;
padding: 0;
list-style: none;
counter-reset: trace;
}
.trace-panel li {
position: relative;
min-height: 30px;
padding-left: 34px;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
counter-increment: trace;
}
.trace-panel li::before {
position: absolute;
left: 0;
top: 0;
display: grid;
width: 24px;
height: 24px;
place-items: center;
border-radius: 8px;
background: var(--surface-strong);
color: var(--ink);
content: counter(trace);
font-size: 11px;
font-weight: 800;
}
.trace-panel li[data-state="success"]::before {
background: var(--accent-soft);
color: var(--accent-ink);
}
.trace-panel li[data-state="error"]::before {
background: var(--danger-soft);
color: var(--danger);
}
.trace-panel li strong {
display: block;
color: var(--ink);
}
.payload-heading {
display: flex;
align-items: center;
justify-content: space-between;
}
.payload-panel pre {
min-height: 216px;
max-height: 360px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
@media (prefers-color-scheme: dark) {
:root {
--page: #111713;
--surface: #19211c;
--surface-soft: #202923;
--surface-strong: #29342d;
--ink: #edf4ef;
--muted: #a4b1a8;
--line: #354139;
--accent: #58b98a;
--accent-soft: #243f32;
--accent-ink: #bdebd3;
--warning: #f2c873;
--warning-soft: #463b23;
--danger: #f0a09a;
--danger-soft: #4b2928;
--shadow: 0 18px 55px rgba(0, 0, 0, 0.22);
}
.primary-button,
.device-icon,
.safety-note > span {
color: #102019;
}
}
@media (max-width: 980px) {
.workspace-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.demo-shell {
width: min(100% - 24px, 1440px);
padding-top: 24px;
}
.page-header {
align-items: flex-start;
flex-direction: column;
}
.pipeline {
justify-content: flex-start;
overflow-x: auto;
}
.pipeline span {
white-space: nowrap;
}
.field-grid,
.card-controls,
.result-grid {
grid-template-columns: 1fr;
}
.field-wide {
grid-column: auto;
}
.readout-grid {
grid-template-columns: 1fr 1fr;
}
.command-composer {
grid-template-columns: 1fr;
}
}
@@ -15,6 +15,12 @@ _FRACTION = re.compile(
r"([零〇一二两三四五六七八九十百点\d.]+)" r"([零〇一二两三四五六七八九十百点\d.]+)"
) )
_SLASH_FRACTION = re.compile(r"(?<!\d)(\d+)\s*/\s*(\d+)(?!\d)") _SLASH_FRACTION = re.compile(r"(?<!\d)(\d+)\s*/\s*(\d+)(?!\d)")
_CHINESE_UNIT_VALUE = re.compile(
r"(?P<value>[零〇一二两三四五六七八九十百]+"
r"(?:点[零〇一二两三四五六七八九]+)?)"
r"(?P<spacing>\s*)"
r"(?P<unit>摄氏度|℃|度|档|级)"
)
_DIGITS = { _DIGITS = {
"零": 0, "零": 0,
"〇": 0, "〇": 0,
@@ -76,6 +82,24 @@ def _number(token: str) -> int | float | None:
return int(value) if value.is_integer() else value return int(value) if value.is_integer() else value
def normalize_chinese_unit_values(text: str) -> str:
"""Convert explicitly unit-qualified Chinese numbers to Arabic digits.
Keeping the unit in the match is intentional: discourse words such as
``一下`` or ``一点`` must not accidentally become control values. Both
typed text and ASR text can call this helper before their domain grammar,
so value recognition does not depend on which input path was used.
"""
def replace(match: re.Match[str]) -> str:
value = _number(match.group("value"))
if value is None:
return match.group(0)
return f"{value}{match.group('spacing')}{match.group('unit')}"
return _CHINESE_UNIT_VALUE.sub(replace, text)
def parse_percentage_value(text: str) -> int | float | None: def parse_percentage_value(text: str) -> int | float | None:
"""Return an explicitly spoken percentage/ratio/extreme position. """Return an explicitly spoken percentage/ratio/extreme position.
@@ -9,7 +9,10 @@ from __future__ import annotations
import re import re
from typing import Any from typing import Any
from cognitive_agent.application.chinese_values import parse_percentage_value from cognitive_agent.application.chinese_values import (
normalize_chinese_unit_values,
parse_percentage_value,
)
WINDOW_TARGETS = { WINDOW_TARGETS = {
"全部": "all", "全部": "all",
@@ -177,7 +180,8 @@ _EXPLICIT_TARGET_PATTERNS = {
re.IGNORECASE, re.IGNORECASE,
), ),
"seat": re.compile( "seat": re.compile(
r"主驾|驾驶位|驾驶席|司机位|左前(?:座|座椅|座位)?|" r"主驾|主驾驶(?:座|座椅|席)?|驾驶位|驾驶席|驾驶座(?:椅)?|"
r"司机位|司机座(?:椅)?|左前(?:座|座椅|座位)?|"
r"副驾(?:驶|位)?|乘客(?:位|座|座椅|座位)?|右前(?:座|座椅|座位)?|" r"副驾(?:驶|位)?|乘客(?:位|座|座椅|座位)?|右前(?:座|座椅|座位)?|"
r"前排|后排|左后|右后|所有座椅|全部座椅|全车座椅|" r"前排|后排|左后|右后|所有座椅|全部座椅|全车座椅|"
r"seat\s+(?:driver|front-left|passenger|front-right)", r"seat\s+(?:driver|front-left|passenger|front-right)",
@@ -561,7 +565,11 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
clause = f"{clause}{continuation}" clause = f"{clause}{continuation}"
continuation_index += 1 continuation_index += 1
index = continuation_index - 1 index = continuation_index - 1
if re.search(r"主驾|驾驶位|司机位|左前座椅", clause): if re.search(
r"主驾|主驾驶(?:座|座椅|席)?|驾驶位|驾驶席|"
r"驾驶座(?:椅)?|司机位|司机座(?:椅)?|左前座椅",
clause,
):
inherited_driver_scope = True inherited_driver_scope = True
elif re.search(r"副驾|副驾驶|乘客|右前", clause): elif re.search(r"副驾|副驾驶|乘客|右前", clause):
inherited_driver_scope = False inherited_driver_scope = False
@@ -592,7 +600,13 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
re.IGNORECASE, re.IGNORECASE,
): ):
return [] return []
explicit_driver = bool(re.search(r"主驾|驾驶位|司机位|左前座椅", compact)) explicit_driver = bool(
re.search(
r"主驾|主驾驶(?:座|座椅|席)?|驾驶位|驾驶席|"
r"驾驶座(?:椅)?|司机位|司机座(?:椅)?|左前座椅",
compact,
)
)
if not explicit_driver and not _looks_like_seat_request(compact): if not explicit_driver and not _looks_like_seat_request(compact):
return [] return []
default_reason = "用户未指定座椅目标,按驾驶台 CH1 安全默认选择主驾座椅。" default_reason = "用户未指定座椅目标,按驾驶台 CH1 安全默认选择主驾座椅。"
@@ -950,7 +964,7 @@ def _parse_chinese_window_commands(compact: str) -> list[dict[str, Any]]:
def _parse_chinese_commands(text: str) -> list[dict[str, Any]]: def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
compact = re.sub(r"\s+", "", text) compact = re.sub(r"\s+", "", normalize_chinese_unit_values(text))
seat_text = _scoped_text( seat_text = _scoped_text(
compact, compact,
r"座椅|座位|靠背|椅背|坐盆|坐垫|腿托|脚托|按摩|记忆|预设|" r"座椅|座位|靠背|椅背|坐盆|坐垫|腿托|脚托|按摩|记忆|预设|"
@@ -981,7 +995,7 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
temperature = re.search( temperature = re.search(
r"(?:空调(?:温度)?|温度)(?:都)?" r"(?:空调(?:温度)?|温度)(?:都)?"
r"(?:调整到|调整为|调到|调为|设为|设置为|调至|到|为)?" r"(?:调整到|调整为|调到|调成|调为|设到|设为|设置为|调至|到|为)?"
r"(1[89]|2\d|3[0-2])(?:摄氏)?(?:℃|度)?(?![\d.])", r"(1[89]|2\d|3[0-2])(?:摄氏)?(?:℃|度)?(?![\d.])",
climate_text, climate_text,
) )
@@ -1340,6 +1354,7 @@ def _drop_implicit_passenger_ch0_candidates(
ambiguities.append( ambiguities.append(
{ {
"field": "seat.target_scope", "field": "seat.target_scope",
"reason": "implicit_driver_default_unresolved",
"question": ( "question": (
"未指定座椅目标时默认主驾 CH1;当前请求无法安全映射为已验证的主驾能力," "未指定座椅目标时默认主驾 CH1;当前请求无法安全映射为已验证的主驾能力,"
"且不会转入副驾 CH0。" "且不会转入副驾 CH0。"
@@ -133,7 +133,9 @@ CONVERSATION_ID_PATTERN = re.compile(r"^conv-[a-z0-9-]{6,80}$")
MODE_EDITOR_REQUEST_ID_PATTERN = re.compile(r"^req-[a-z0-9-]{6,100}$") MODE_EDITOR_REQUEST_ID_PATTERN = re.compile(r"^req-[a-z0-9-]{6,100}$")
SEAT_SCOPE_PATTERN = re.compile(r"\bseats?\b|座椅|坐椅", re.IGNORECASE) SEAT_SCOPE_PATTERN = re.compile(r"\bseats?\b|座椅|坐椅", re.IGNORECASE)
DRIVER_SEAT_SCOPE_PATTERN = re.compile( DRIVER_SEAT_SCOPE_PATTERN = re.compile(
r"\bdriver\b|\bfront[-_ ]?left\b|\bleft[-_ ]?front\b|主驾|驾驶位|司机位|左前", r"\bdriver\b|\bfront[-_ ]?left\b|\bleft[-_ ]?front\b|"
r"主驾|主驾驶(?:座|座椅|席)?|驾驶位|驾驶席|驾驶座(?:椅)?|"
r"司机位|司机座(?:椅)?|左前",
re.IGNORECASE, re.IGNORECASE,
) )
PASSENGER_SEAT_SCOPE_PATTERN = re.compile( PASSENGER_SEAT_SCOPE_PATTERN = re.compile(
@@ -2257,13 +2259,9 @@ class SeatDemoService:
local_preview=local_preview, local_preview=local_preview,
model_parse_source=model_parse_source, model_parse_source=model_parse_source,
) )
if self._is_dynamic_mode_stop_request(utterance): dynamic_mode_stop = self._is_dynamic_mode_stop_request(utterance)
if dynamic_mode_stop:
parsed = self._apply_dynamic_mode_stop_request(parsed, utterance) parsed = self._apply_dynamic_mode_stop_request(parsed, utterance)
else:
parsed = self._apply_mode_profiles(
parsed, matched_modes, context, vehicle_profile
)
parsed = self._mark_control_parse_source(parsed, "local_rule")
relative_resolution = ( relative_resolution = (
self.native_can_relative_resolver.resolve(utterance, context) self.native_can_relative_resolver.resolve(utterance, context)
if self.native_can_relative_resolver.matches(utterance) if self.native_can_relative_resolver.matches(utterance)
@@ -2282,6 +2280,19 @@ class SeatDemoService:
relative_resolution.claimed_controls if relative_resolution else None relative_resolution.claimed_controls if relative_resolution else None
), ),
) )
if not dynamic_mode_stop:
# Apply repository-backed modes after the final native-CAN
# merge. Otherwise that merge can discard a valid passenger
# mode operation while enforcing its generic unscoped-seat
# CH1 default.
parsed = self._apply_mode_profiles(
parsed,
matched_modes,
context,
vehicle_profile,
utterance=utterance,
)
parsed = self._mark_control_parse_source(parsed, "local_rule")
if direct_mode_profile is not None: if direct_mode_profile is not None:
parsed = self._mark_control_parse_source( parsed = self._mark_control_parse_source(
parsed, parsed,
@@ -3327,6 +3338,8 @@ class SeatDemoService:
matched_modes: list[ModeProfile], matched_modes: list[ModeProfile],
context: dict[str, Any], context: dict[str, Any],
vehicle_profile: VehicleProfile, vehicle_profile: VehicleProfile,
*,
utterance: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
if not matched_modes: if not matched_modes:
return parsed return parsed
@@ -3354,6 +3367,12 @@ class SeatDemoService:
] ]
return rejected return rejected
parsed = self._apply_implicit_mode_target_policy(
parsed,
matched_modes,
utterance,
)
safety_ambiguities = [ safety_ambiguities = [
item item
for item in parsed.get("ambiguities", []) for item in parsed.get("ambiguities", [])
@@ -3430,6 +3449,38 @@ class SeatDemoService:
) )
return merged return merged
@staticmethod
def _apply_implicit_mode_target_policy(
parsed: dict[str, Any],
matched_modes: list[ModeProfile],
utterance: str,
) -> dict[str, Any]:
"""Let one matched mode own only an omitted seat target.
The native CH1 parser can add an implicit-driver ambiguity before or
after mode injection. A unique repository-backed mode already carries
authoritative target scopes on every operation, so that one tagged
default is redundant. Explicit scopes and all other safety questions
remain untouched.
"""
if len(matched_modes) != 1 or (
DRIVER_SEAT_SCOPE_PATTERN.search(utterance)
or PASSENGER_SEAT_SCOPE_PATTERN.search(utterance)
):
return parsed
ambiguities = list(parsed.get("ambiguities", []))
filtered = [
item
for item in ambiguities
if item.get("reason") != "implicit_driver_default_unresolved"
]
if len(filtered) == len(ambiguities):
return parsed
resolved = deepcopy(parsed)
resolved["ambiguities"] = filtered
return resolved
@staticmethod @staticmethod
def _is_dynamic_mode_stop_request(utterance: str) -> bool: def _is_dynamic_mode_stop_request(utterance: str) -> bool:
compact = re.sub(r"\s+", "", utterance) compact = re.sub(r"\s+", "", utterance)
@@ -165,6 +165,7 @@ class CapabilityResolver:
*, *,
target_scope: str | None, target_scope: str | None,
target_explicit: bool, target_explicit: bool,
action: str = "apply",
) -> ModeResolution: ) -> ModeResolution:
matches = self.mode_profiles.match_text(text, vehicle_id="denza_n7") matches = self.mode_profiles.match_text(text, vehicle_id="denza_n7")
if not matches: if not matches:
@@ -172,7 +173,24 @@ class CapabilityResolver:
if len(matches) > 1: if len(matches) > 1:
return ModeResolution("ambiguous", None, ("multiple_mode_profiles_matched",)) return ModeResolution("ambiguous", None, ("multiple_mode_profiles_matched",))
profile = matches[0] profile = matches[0]
operation_scopes = {operation.target_scope for operation in profile.operations} selected_operations = profile.operations
if action == "stop":
dynamic_operations = tuple(
operation
for operation in profile.operations
if operation.capability == "seat.start_dynamic_mode"
)
if not dynamic_operations:
return ModeResolution(
"unsupported",
profile,
(f"mode_profile.{profile.id}不支持关闭指令",),
)
selected_operations = dynamic_operations
operation_scopes = {
operation.target_scope for operation in selected_operations
}
if target_explicit and target_scope not in operation_scopes: if target_explicit and target_scope not in operation_scopes:
scopes = ",".join(sorted(operation_scopes)) scopes = ",".join(sorted(operation_scopes))
return ModeResolution( return ModeResolution(
@@ -183,22 +201,28 @@ class CapabilityResolver:
payloads: list[dict[str, Any]] = [] payloads: list[dict[str, Any]] = []
issues: list[str] = [] issues: list[str] = []
for operation in profile.operations: for operation in selected_operations:
capability = (
"seat.stop_dynamic_mode"
if action == "stop"
else operation.capability
)
parameters = {} if action == "stop" else operation.parameters
resolution = self.resolve( resolution = self.resolve(
domain=operation.domain, domain=operation.domain,
semantic_property="", semantic_property="",
target_scope=operation.target_scope, target_scope=operation.target_scope,
target_device=None, target_device=None,
operation=operation.operation, operation="set" if action == "stop" else operation.operation,
parameters=operation.parameters, parameters=parameters,
source_text=text, source_text=text,
preferred_capability=operation.capability, preferred_capability=capability,
) )
if resolution.status != "executable" or resolution.executor_payload is None: if resolution.status != "executable" or resolution.executor_payload is None:
issues.extend(resolution.issues or ("mode_operation_not_executable",)) issues.extend(resolution.issues or ("mode_operation_not_executable",))
continue continue
payloads.append(resolution.executor_payload) payloads.append(resolution.executor_payload)
if issues or len(payloads) != len(profile.operations): if issues or len(payloads) != len(selected_operations):
return ModeResolution("unsupported", profile, tuple(dict.fromkeys(issues))) return ModeResolution("unsupported", profile, tuple(dict.fromkeys(issues)))
return ModeResolution("executable", profile, executor_payloads=tuple(payloads)) return ModeResolution("executable", profile, executor_payloads=tuple(payloads))
@@ -9,7 +9,10 @@ from dataclasses import dataclass, replace
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from cognitive_agent.application.chinese_values import parse_percentage_value from cognitive_agent.application.chinese_values import (
normalize_chinese_unit_values,
parse_percentage_value,
)
from cognitive_agent.application.native_can_intent import merge_native_can_intent from cognitive_agent.application.native_can_intent import merge_native_can_intent
from cognitive_agent.infrastructure.speech.capability import CapabilityResolver from cognitive_agent.infrastructure.speech.capability import CapabilityResolver
from cognitive_agent.infrastructure.speech.contracts import ( from cognitive_agent.infrastructure.speech.contracts import (
@@ -858,11 +861,13 @@ class UnderstandingTranscriptProcessor:
mode_profile = self.resolver.match_mode(text) mode_profile = self.resolver.match_mode(text)
if mode_profile is not None: if mode_profile is not None:
mode_operation = "stop" if action == "off" else "apply"
mode_started = time.perf_counter() mode_started = time.perf_counter()
mode_resolution = self.resolver.resolve_mode( mode_resolution = self.resolver.resolve_mode(
text, text,
target_scope=scope, target_scope=scope,
target_explicit=explicit_scope, target_explicit=explicit_scope,
action=mode_operation,
) )
resolution_ms += (time.perf_counter() - mode_started) * 1000 resolution_ms += (time.perf_counter() - mode_started) * 1000
mode_issues = tuple(mode_resolution.issues) mode_issues = tuple(mode_resolution.issues)
@@ -873,7 +878,7 @@ class UnderstandingTranscriptProcessor:
target_scope=command_scope, target_scope=command_scope,
target_device="seat", target_device="seat",
property_name="seat.mode", property_name="seat.mode",
operation="apply", operation=mode_operation,
parameters={"mode_profile_id": mode_profile.id}, parameters={"mode_profile_id": mode_profile.id},
value=mode_profile.name, value=mode_profile.name,
unit=None, unit=None,
@@ -954,6 +959,14 @@ class UnderstandingTranscriptProcessor:
parameters["state"] = action if action in {"on", "off"} else "on" parameters["state"] = action if action in {"on", "off"} else "on"
if value is not None: if value is not None:
parameters["level"] = int(value) parameters["level"] = int(value)
elif action == "on":
# The passenger-seat text parser executes a bare ON at
# its lowest supported level; verified CH1 driver control
# uses level 2 for the same language. Preserve the
# target-specific executable default in speech semantics,
# but keep clean_text faithful to what the user said.
parameters["level"] = 2 if scope == "driver" else 1
qualifiers["implicit_parameters"] = ["level"]
else: else:
local = ("seat_thermal_action_incomplete",) local = ("seat_thermal_action_incomplete",)
commands.append( commands.append(
@@ -1317,7 +1330,7 @@ class UnderstandingTranscriptProcessor:
@staticmethod @staticmethod
def _numeric_value(text: str) -> int | float | None: def _numeric_value(text: str) -> int | float | None:
text = _NON_NUMERIC_ONE.sub("", text) text = normalize_chinese_unit_values(_NON_NUMERIC_ONE.sub("", text))
match = _NUMBER.search(text) match = _NUMBER.search(text)
if match: if match:
value = float(match.group(1)) value = float(match.group(1))
@@ -1415,6 +1428,8 @@ class UnderstandingTranscriptProcessor:
return f"将{self._scoped_target(command, feature)}设置为{level}" return f"将{self._scoped_target(command, feature)}设置为{level}"
return f"{'打开' if command.operation == 'on' else '关闭'}{self._scoped_target(command, feature)}" return f"{'打开' if command.operation == 'on' else '关闭'}{self._scoped_target(command, feature)}"
if prop == "seat.mode": if prop == "seat.mode":
if command.operation == "stop":
return f"关闭{command.value}"
return f"将{self._scoped_target(command, '座椅')}调整为{command.value}" return f"将{self._scoped_target(command, '座椅')}调整为{command.value}"
if prop == "seat.backrest_recline": if prop == "seat.backrest_recline":
direction = ( direction = (
@@ -212,6 +212,9 @@ ACTION_ALIASES: dict[str, tuple[str, ...]] = {
"调成", "调成",
"调为", "调为",
"变成", "变成",
"进入",
"切换到",
"切换为",
"设为", "设为",
"设成", "设成",
"设到", "设到",
+85 -3
View File
@@ -532,6 +532,8 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
speech_utterance_id = str( speech_utterance_id = str(
payload.get("speech_utterance_id", "") payload.get("speech_utterance_id", "")
).strip() ).strip()
speech_understanding: dict[str, object] | None = None
speech_peripheral_route = False
if speech_utterance_id: if speech_utterance_id:
if path != "/api/requests": if path != "/api/requests":
raise ValueError( raise ValueError(
@@ -540,13 +542,28 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
speech_understanding = self.speech.cached_result( speech_understanding = self.speech.cached_result(
speech_utterance_id speech_utterance_id
) )
if str(speech_understanding.get("clean_text", "")).strip() != utterance: clean_text = str(
speech_understanding.get("clean_text", "")
).strip()
raw_text = str(
speech_understanding.get("raw_text", "")
).strip()
peripheral_routing = self._speech_peripheral_routing(raw_text)
peripheral_transcript = bool(
peripheral_routing.get("execution_eligible") and raw_text
)
expected_text = raw_text if peripheral_transcript else clean_text
if utterance != expected_text:
raise SpeechRecognitionError( raise SpeechRecognitionError(
"speech_text_changed", "speech_text_changed",
"语音文本已被修改,请确认修改后的内容再执行。", "语音文本已被修改,请确认修改后的内容再执行。",
status=409, status=409,
) )
if speech_understanding.get("execution_eligible") is not True: speech_peripheral_route = peripheral_transcript
if (
speech_understanding.get("execution_eligible") is not True
and not speech_peripheral_route
):
raise SpeechRecognitionError( raise SpeechRecognitionError(
"speech_not_executable", "speech_not_executable",
"语音意图尚未通过能力或参数校验,已拒绝执行。", "语音意图尚未通过能力或参数校验,已拒绝执行。",
@@ -554,7 +571,8 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
) )
provider = "local" provider = "local"
common_arguments["provider"] = provider common_arguments["provider"] = provider
common_arguments["_speech_understanding"] = speech_understanding if not speech_peripheral_route:
common_arguments["_speech_understanding"] = speech_understanding
if ( if (
path != "/api/can/commands" path != "/api/can/commands"
and "application/x-ndjson" in self.headers.get("Accept", "") and "application/x-ndjson" in self.headers.get("Accept", "")
@@ -620,6 +638,14 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
) )
else: else:
result = self.service.process(**common_arguments) result = self.service.process(**common_arguments)
if speech_peripheral_route and speech_understanding is not None:
result["speech_understanding"] = {
"utterance_id": speech_utterance_id,
"raw_text": speech_understanding.get("raw_text"),
"clean_text": speech_understanding.get("clean_text"),
"semantic_status": speech_understanding.get("semantic_status"),
"routing": "validated_peripheral_text",
}
utterance = str(result.get("utterance") or utterance) utterance = str(result.get("utterance") or utterance)
self.vehicle_display_sync.update_task( self.vehicle_display_sync.update_task(
status=str(result.get("status", "succeeded")), status=str(result.get("status", "succeeded")),
@@ -815,6 +841,54 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
) )
return result return result
def _speech_peripheral_routing(self, utterance: str) -> dict[str, object]:
"""Validate whether an ASR transcript may enter peripheral routing.
Vehicle speech keeps using its cached structured semantic payload. A
learned peripheral cannot be represented by the static vehicle
capability catalog, so it may instead reuse the exact server-owned ASR
transcript only after the live NFC binding and learned action resolve.
"""
text = str(utterance or "").strip()
if not text:
return {"execution_eligible": False, "action_count": 0}
try:
catalog = self.extension.list_peripheral_capabilities()
nfc_snapshot = self.nfc.snapshot()
partition = partition_active_peripheral_intent(
text,
catalog,
nfc_snapshot,
)
entries = list(partition.get("peripheral_actions") or [])
unresolved = list(
partition.get("unresolved_peripheral_clauses") or []
)
denied = list(partition.get("denied_peripheral_actions") or [])
plan_catalog = active_peripheral_plan_catalog(
text,
catalog,
nfc_snapshot,
)
direct = None
if not entries and not plan_catalog and not unresolved and not denied:
direct = resolve_active_peripheral_command(
text,
catalog,
nfc_snapshot,
)
except (ExtensionIntegrationError, GatewayError, ValueError):
return {"execution_eligible": False, "action_count": 0}
action_count = len(entries) or (1 if direct is not None else 0)
if plan_catalog:
action_count = max(action_count, 1)
return {
"execution_eligible": bool(action_count and not unresolved and not denied),
"action_count": action_count,
"has_ordered_plan": bool(plan_catalog),
}
@staticmethod @staticmethod
def _residual_has_vehicle_language(utterance: str) -> bool: def _residual_has_vehicle_language(utterance: str) -> bool:
"""Recognize only obvious vehicle-domain text in a claimed mixed request.""" """Recognize only obvious vehicle-domain text in a claimed mixed request."""
@@ -2206,6 +2280,14 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
self.rfile.read(content_length), self.rfile.read(content_length),
utterance_id=self.headers.get("X-Speech-Utterance-Id"), utterance_id=self.headers.get("X-Speech-Utterance-Id"),
) )
raw_text = str(result.get("raw_text") or "").strip()
peripheral_routing = self._speech_peripheral_routing(raw_text)
if peripheral_routing.get("execution_eligible"):
result = {
**result,
"routing_text": raw_text,
"peripheral_routing": peripheral_routing,
}
self._json(result) self._json(result)
except SpeechRecognitionError as exc: except SpeechRecognitionError as exc:
self._json( self._json(
+146 -6
View File
@@ -624,7 +624,7 @@ operations:
) as response: ) as response:
gesture_script = response.read().decode("utf-8") gesture_script = response.read().decode("utf-8")
with self.opener.open( with self.opener.open(
f"{self.base_url}/console-v2.js?v=delivery-state-54", timeout=3 f"{self.base_url}/console-v2.js?v=delivery-state-55", timeout=3
) as response: ) as response:
console_script = response.read().decode("utf-8") console_script = response.read().decode("utf-8")
with self.opener.open( with self.opener.open(
@@ -632,7 +632,7 @@ operations:
) as response: ) as response:
console_styles = response.read().decode("utf-8") console_styles = response.read().decode("utf-8")
with self.opener.open( with self.opener.open(
f"{self.base_url}/speech-input.js?v=sensevoice-6", timeout=3 f"{self.base_url}/speech-input.js?v=sensevoice-7", timeout=3
) as response: ) as response:
speech_script = response.read().decode("utf-8") speech_script = response.read().decode("utf-8")
with self.opener.open( with self.opener.open(
@@ -685,9 +685,9 @@ operations:
self.assertIn('href="/styles.css?v=dual-seat-21"', html) self.assertIn('href="/styles.css?v=dual-seat-21"', html)
self.assertIn('src="/gesture-control.js?v=android-pad-9"', html) self.assertIn('src="/gesture-control.js?v=android-pad-9"', html)
self.assertNotIn('class="vehicle-system-hud"', html) self.assertNotIn('class="vehicle-system-hud"', html)
self.assertIn('src="/console-v2.js?v=delivery-state-54"', html) self.assertIn('src="/console-v2.js?v=delivery-state-55"', html)
self.assertIn('href="/console-v2.css?v=delivery-state-50"', html) self.assertIn('href="/console-v2.css?v=delivery-state-50"', html)
self.assertIn('src="/speech-input.js?v=sensevoice-6"', html) self.assertIn('src="/speech-input.js?v=sensevoice-7"', html)
self.assertIn('href="/speech-input.css?v=sensevoice-5"', html) self.assertIn('href="/speech-input.css?v=sensevoice-5"', html)
self.assertIn('id="speechDictationButton"', html) self.assertIn('id="speechDictationButton"', html)
self.assertIn('class="speech-waveform"', html) self.assertIn('class="speech-waveform"', html)
@@ -959,9 +959,9 @@ operations:
self.assertIn("CAN 指令已提交,等待车辆状态确认", console_script) self.assertIn("CAN 指令已提交,等待车辆状态确认", console_script)
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script) self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
self.assertIn('"cognitive-agent:module-change"', app_script) self.assertIn('"cognitive-agent:module-change"', app_script)
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v227"', service_worker) self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v228"', service_worker)
self.assertIn('"/app.js?v=dual-seat-114"', service_worker) self.assertIn('"/app.js?v=dual-seat-114"', service_worker)
self.assertIn('"/speech-input.js?v=sensevoice-6"', service_worker) self.assertIn('"/speech-input.js?v=sensevoice-7"', service_worker)
self.assertIn('"/speech-input.css?v=sensevoice-5"', service_worker) self.assertIn('"/speech-input.css?v=sensevoice-5"', service_worker)
self.assertIn('"/speech-audio-worklet.js?v=sensevoice-4"', service_worker) self.assertIn('"/speech-audio-worklet.js?v=sensevoice-4"', service_worker)
self.assertIn('fetch("/api/speech/transcribe"', speech_script) self.assertIn('fetch("/api/speech/transcribe"', speech_script)
@@ -979,6 +979,9 @@ operations:
self.assertIn("speechExecutionEligible", app_script) self.assertIn("speechExecutionEligible", app_script)
self.assertIn("speech_utterance_id", app_script) self.assertIn("speech_utterance_id", app_script)
self.assertIn("speechUtteranceId", speech_script) self.assertIn("speechUtteranceId", speech_script)
self.assertIn("result.peripheral_routing?.execution_eligible", speech_script)
self.assertIn("result.routing_text", speech_script)
self.assertIn('speech_semantic: "语音语义"', console_script)
self.assertIn("语音意图已识别,但当前车辆能力不支持执行", app_script) self.assertIn("语音意图已识别,但当前车辆能力不支持执行", app_script)
self.assertIn("语音控制参数不完整,请补充后再执行", app_script) self.assertIn("语音控制参数不完整,请补充后再执行", app_script)
self.assertIn(".speech-dictation.is-listening", speech_styles) self.assertIn(".speech-dictation.is-listening", speech_styles)
@@ -1741,6 +1744,143 @@ operations:
self.assertEqual(operation["domain"], "self_extension") self.assertEqual(operation["domain"], "self_extension")
self.assertTrue(operation["telemetry"]["gateway_acknowledged"]) self.assertTrue(operation["telemetry"]["gateway_acknowledged"])
def test_speech_routes_exact_asr_text_to_learned_peripheral_and_ui_config(
self,
) -> None:
original_extension = SeatDemoHandler.extension
original_nfc = SeatDemoHandler.nfc
original_speech = SeatDemoHandler.speech
extension = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(
self.extension_temporary_directory.name,
"speech-nfc-route.sqlite3",
),
)
extension.sync_peripheral_catalog(
[
{
"id": "cap-third-party-light-on",
"status": "learned",
"enabled": True,
"device_id": "third-party-light",
"device_name": "第三方氛围灯",
"object_names": ["第三方氛围灯"],
"card_uid": "1A53DD95",
"action": "开启",
"action_names": ["打开", "开启"],
"slot": 9,
}
]
)
SeatDemoHandler.extension = extension
SeatDemoHandler.nfc = SimpleNamespace(
snapshot=lambda: {
"revision": 1,
"reader": {"online": True},
"card": {"action": "present", "uid": "1A53DD95"},
}
)
understanding = UnderstandingTranscriptProcessor(REPOSITORY_ROOT).process(
"打开第三方氛围灯"
).to_dict()
understanding["utterance_id"] = "e2e-speech-peripheral"
class SpeechStub:
@staticmethod
def transcribe_wav(
content: bytes,
*,
utterance_id: str | None = None,
) -> dict[str, object]:
if content != b"RIFF-speech-peripheral":
raise AssertionError("unexpected audio payload")
return {**understanding, "utterance_id": utterance_id}
@staticmethod
def cached_result(utterance_id: str) -> dict[str, object]:
if utterance_id != "e2e-speech-peripheral":
raise AssertionError("unexpected speech utterance id")
return dict(understanding)
SeatDemoHandler.speech = SpeechStub()
try:
transcription_request = urllib.request.Request(
f"{self.base_url}/api/speech/transcribe",
method="POST",
headers={
"Content-Type": "audio/wav",
"X-Speech-Utterance-Id": "e2e-speech-peripheral",
},
data=b"RIFF-speech-peripheral",
)
with self.opener.open(transcription_request, timeout=3) as response:
transcription = json.loads(response.read().decode("utf-8"))
wrong_target_request = urllib.request.Request(
f"{self.base_url}/api/requests",
method="POST",
headers={"Content-Type": "application/json"},
data=json.dumps(
{
"utterance": understanding["clean_text"],
"speech_utterance_id": "e2e-speech-peripheral",
"provider": "local",
"conversation_id": "conv-e2e-speech-wrong-target",
"execution_mode": "dry_run",
},
ensure_ascii=False,
).encode("utf-8"),
)
with self.assertRaises(urllib.error.HTTPError) as rejected:
self.opener.open(wrong_target_request, timeout=3)
self.assertEqual(rejected.exception.code, 409)
rejected_payload = json.loads(
rejected.exception.read().decode("utf-8")
)
self.assertEqual(rejected_payload["error"], "speech_text_changed")
control_request = urllib.request.Request(
f"{self.base_url}/api/requests",
method="POST",
headers={"Content-Type": "application/json"},
data=json.dumps(
{
"utterance": transcription["routing_text"],
"speech_utterance_id": "e2e-speech-peripheral",
"provider": "openai",
"conversation_id": "conv-e2e-speech-peripheral",
"execution_mode": "dry_run",
"client_type": "web_console",
},
ensure_ascii=False,
).encode("utf-8"),
)
with self.opener.open(control_request, timeout=3) as response:
result = json.loads(response.read().decode("utf-8"))
finally:
SeatDemoHandler.extension = original_extension
SeatDemoHandler.nfc = original_nfc
SeatDemoHandler.speech = original_speech
self.assertEqual(transcription["routing_text"], "打开第三方氛围灯")
self.assertTrue(
transcription["peripheral_routing"]["execution_eligible"]
)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(result["orchestration_route"], "nfc_peripheral")
self.assertEqual(set(result["function_configs"]), {"self_extension"})
operation = result["function_configs"]["self_extension"]["spec"][
"operations"
][0]
self.assertEqual(operation["capability"], "infrared.emit")
self.assertEqual(operation["parameters"]["slot"], 9)
self.assertEqual(operation["parse_source"], "local_rule")
self.assertEqual(
result["speech_understanding"]["routing"],
"validated_peripheral_text",
)
def test_main_request_combines_explicit_ir_clause_with_existing_can_parser(self) -> None: def test_main_request_combines_explicit_ir_clause_with_existing_can_parser(self) -> None:
original_extension = SeatDemoHandler.extension original_extension = SeatDemoHandler.extension
original_nfc = SeatDemoHandler.nfc original_nfc = SeatDemoHandler.nfc
+4 -1
View File
@@ -58,5 +58,8 @@
{"name":"elliptical_temperature","raw":"空调二十四度","clean":"将空调设置为24度。","capabilities":["climate.set_temperature"],"cancelled":0,"uncertain":false,"execution_eligible":true}, {"name":"elliptical_temperature","raw":"空调二十四度","clean":"将空调设置为24度。","capabilities":["climate.set_temperature"],"cancelled":0,"uncertain":false,"execution_eligible":true},
{"name":"elliptical_value_continuation","raw":"打开空调,然后二十四度","clean":"打开空调,然后将空调设置为24度。","capabilities":["climate.set_power","climate.set_temperature"],"cancelled":0,"uncertain":false,"execution_eligible":true}, {"name":"elliptical_value_continuation","raw":"打开空调,然后二十四度","clean":"打开空调,然后将空调设置为24度。","capabilities":["climate.set_power","climate.set_temperature"],"cancelled":0,"uncertain":false,"execution_eligible":true},
{"name":"punctuated_ambient_slots","raw":"氛围灯打开,调成蓝色,亮度百分之五十","clean":"打开氛围灯,然后将氛围灯颜色调整为蓝色并将亮度设置为50%。","capabilities":["ambient_light.set_power","ambient_light.set_illumination"],"cancelled":0,"uncertain":false,"execution_eligible":true}, {"name":"punctuated_ambient_slots","raw":"氛围灯打开,调成蓝色,亮度百分之五十","clean":"打开氛围灯,然后将氛围灯颜色调整为蓝色并将亮度设置为50%。","capabilities":["ambient_light.set_power","ambient_light.set_illumination"],"cancelled":0,"uncertain":false,"execution_eligible":true},
{"name":"non_control_continuation_fails_closed","raw":"打开空调,然后开玩笑。","clean":"打开空调。","capabilities":["climate.set_power"],"cancelled":0,"uncertain":true,"execution_eligible":false} {"name":"non_control_continuation_fails_closed","raw":"打开空调,然后开玩笑。","clean":"打开空调。","capabilities":["climate.set_power"],"cancelled":0,"uncertain":true,"execution_eligible":false},
{"name":"rocking_horse_mode_unscoped","raw":"将座椅调整为木马模式。","clean":"将座椅调整为木马模式。","capabilities":[null],"cancelled":0,"uncertain":false,"execution_eligible":true},
{"name":"rocking_horse_mode_enter","raw":"进入木马模式","clean":"将座椅调整为木马模式。","capabilities":[null],"cancelled":0,"uncertain":false,"execution_eligible":true},
{"name":"rocking_horse_mode_stop","raw":"关闭木马模式","clean":"关闭木马模式。","capabilities":[null],"cancelled":0,"uncertain":false,"execution_eligible":true}
] ]
+1 -1
View File
@@ -47,7 +47,7 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None:
): ):
assert asset in html assert asset in html
assert f'"{asset}"' in service_worker assert f'"{asset}"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v227"' in service_worker assert 'const CACHE_VERSION = "cognitive-agent-pad-v228"' in service_worker
def test_vehicle_twin_icons_and_nested_overlays_use_semantic_tokens() -> None: def test_vehicle_twin_icons_and_nested_overlays_use_semantic_tokens() -> None:
+2 -1
View File
@@ -139,4 +139,5 @@ def test_main_parsed_control_panel_renders_validated_infrared_operations() -> No
assert 'frameLabel: "GSCU1B TCP"' in console assert 'frameLabel: "GSCU1B TCP"' in console
assert "已解析控制指令" in console assert "已解析控制指令" in console
assert 'li[data-can-channel="IR"]' in styles assert 'li[data-can-channel="IR"]' in styles
assert "console-v2.js?v=delivery-state-54" in html assert "console-v2.js?v=delivery-state-55" in html
assert 'speech_semantic: "语音语义"' in console
@@ -568,6 +568,73 @@ def test_mode_editor_prepares_one_to_many_mqtt_message_sequences() -> None:
) )
def test_left_sidebar_extracts_speech_and_peripheral_control_commands() -> None:
node = shutil.which("node")
if node is None:
return
console = source("console-v2.js")
helper_start = console.index(" const normalizedCanTarget")
helper_end = console.index("\n\n function renderParsedCanCommands", helper_start)
helpers = console[helper_start:helper_end]
sample = {
"execution_mode": "dry_run",
"function_configs": {
"native_climate": {
"spec": {
"operations": [
{
"id": "speech-temperature",
"capability": "climate.set_temperature",
"target": {"device": "hvac", "zone": "driver"},
"parameters": {"temperature_c": 24},
"parse_source": "speech_semantic",
}
]
}
},
"self_extension": {
"spec": {
"operations": [
{
"id": "speech-peripheral",
"capability": "infrared.emit",
"target": {
"device": "third-party-light",
"name": "第三方氛围灯",
},
"parameters": {"action": "开启", "slot": 9},
"parse_source": "local_rule",
}
]
}
},
},
}
script = (
f"{helpers}\n"
f"const result = {json.dumps(sample, ensure_ascii=False)};\n"
"process.stdout.write(JSON.stringify({"
"commands:parsedCanCommands(result),"
'speechLabel:parsedCommandSourceLabel("speech_semantic")'
"}));"
)
completed = subprocess.run(
[node, "-e", script],
check=True,
capture_output=True,
text=True,
)
output = json.loads(completed.stdout)
assert output["speechLabel"] == "语音语义"
assert len(output["commands"]) == 2
assert {item["channel"] for item in output["commands"]} == {"CAN1", "IR"}
assert {item["command"] for item in output["commands"]} == {
"ac temp driver 24",
"infrared emit 9",
}
def test_interactive_console_keeps_actions_inside_composer_and_collapsed_rails_aligned() -> None: def test_interactive_console_keeps_actions_inside_composer_and_collapsed_rails_aligned() -> None:
console = source("console-v2.js") console = source("console-v2.js")
styles = source("console-v2.css") styles = source("console-v2.css")
+27 -7
View File
@@ -148,7 +148,7 @@ class SpeechCapabilityAlignmentTests(unittest.TestCase):
"passenger", "passenger",
"seat.ventilation", "seat.ventilation",
"on", "on",
{"state": "on"}, {"state": "on", "level": 1},
), ),
), ),
}, },
@@ -432,18 +432,38 @@ class SpeechCapabilityAlignmentTests(unittest.TestCase):
def test_colloquial_seat_aliases_do_not_turn_pragmatic_one_into_level(self) -> None: def test_colloquial_seat_aliases_do_not_turn_pragmatic_one_into_level(self) -> None:
cases = ( cases = (
("把驾驶座通风打开", "driver", "seat.set_driver_ventilation_native"), (
("把乘客座通风打开", "passenger", "seat.set_ventilation"), "把驾驶座通风打开",
("给副驾驶开一下座椅通风", "passenger", "seat.set_ventilation"), "driver",
"seat.set_driver_ventilation_native",
{"state": "on", "level": 2},
),
(
"把乘客座通风打开",
"passenger",
"seat.set_ventilation",
{"state": "on", "level": 1},
),
(
"给副驾驶开一下座椅通风",
"passenger",
"seat.set_ventilation",
{"state": "on", "level": 1},
),
) )
for text, target, capability in cases: for text, target, capability, parameters in cases:
with self.subTest(text=text): with self.subTest(text=text):
result = self.processor.process(text) result = self.processor.process(text)
self.assertTrue(result.execution_eligible) self.assertTrue(result.execution_eligible)
self.assertEqual(len(result.commands), 1) self.assertEqual(len(result.commands), 1)
self.assertEqual(result.commands[0].target_scope, target) self.assertEqual(result.commands[0].target_scope, target)
self.assertEqual(result.commands[0].capability, capability) self.assertEqual(result.commands[0].capability, capability)
self.assertEqual(result.commands[0].parameters, {"state": "on"}) self.assertEqual(result.commands[0].parameters, parameters)
self.assertIn(
"level",
result.commands[0].qualifiers.get("implicit_parameters", ()),
)
self.assertNotIn("设置为", result.clean_text)
explicit_level = self.processor.process("主驾座椅通风开到一档") explicit_level = self.processor.process("主驾座椅通风开到一档")
self.assertEqual(explicit_level.commands[0].parameters["level"], 1) self.assertEqual(explicit_level.commands[0].parameters["level"], 1)
@@ -455,7 +475,7 @@ class SpeechCapabilityAlignmentTests(unittest.TestCase):
) )
expected = ( expected = (
("seat.set_driver_ventilation_native", "driver", {"state": "on", "level": 2}), ("seat.set_driver_ventilation_native", "driver", {"state": "on", "level": 2}),
("seat.set_ventilation", "passenger", {"state": "on"}), ("seat.set_ventilation", "passenger", {"state": "on", "level": 1}),
("climate.set_temperature", "driver", {"temperature_c": 24}), ("climate.set_temperature", "driver", {"temperature_c": 24}),
) )
for text in cases: for text in cases:
@@ -15,6 +15,44 @@ from cognitive_agent.infrastructure.speech.understanding import (
REPOSITORY_ROOT = Path(__file__).resolve().parents[2] REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
def _canonical_actions(result: dict[str, object]) -> list[dict[str, object]]:
intent_ir = result.get("intent_ir")
assert isinstance(intent_ir, dict)
spec = intent_ir.get("spec")
assert isinstance(spec, dict)
actions = spec.get("actions")
assert isinstance(actions, list)
return [
{
"domain": action.get("domain"),
"capability": action.get("capability"),
"target": action.get("target"),
"operation": action.get("operation"),
"parameters": action.get("parameters"),
}
for action in actions
if isinstance(action, dict)
]
def _sorted_canonical_actions(result: dict[str, object]) -> list[dict[str, object]]:
return sorted(
_canonical_actions(result),
key=lambda action: (
str(action["domain"]),
str(action["capability"]),
repr(action["target"]),
repr(action["parameters"]),
),
)
def _mode_service(directory: str) -> SeatDemoService:
service = SeatDemoService(REPOSITORY_ROOT)
service.artifact_root = Path(directory) / "artifacts"
return service
@pytest.mark.parametrize( @pytest.mark.parametrize(
("utterance", "capability", "target", "parameters"), ("utterance", "capability", "target", "parameters"),
( (
@@ -194,3 +232,157 @@ def test_every_registered_native_runtime_capability_keeps_native_bridge_binding(
assert [ assert [
action["capability"] for action in parsed["native_can_actions"] action["capability"] for action in parsed["native_can_actions"]
] == [capability] ] == [capability]
def test_text_and_speech_share_implicit_mode_target_and_start_command() -> None:
processor = UnderstandingTranscriptProcessor(REPOSITORY_ROOT)
understanding = processor.process("将座椅调整为木马模式。").to_dict()
understanding["utterance_id"] = "mode-start-equivalence"
with tempfile.TemporaryDirectory() as directory, patch.dict(
os.environ,
{
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false",
"COGNITIVE_AGENT_MODE_PROFILE_ROOT": str(Path(directory) / "modes"),
},
clear=False,
):
service = _mode_service(directory)
text_result = service.process(
"将座椅调整为木马模式。",
provider="local",
conversation_id="conv-text-mode-start",
)
speech_result = service.process(
understanding["clean_text"],
provider="local",
conversation_id="conv-speech-mode-start",
_speech_understanding=understanding,
)
assert understanding["execution_eligible"] is True
assert text_result["status"] == speech_result["status"] == "succeeded"
assert _canonical_actions(text_result) == _canonical_actions(speech_result)
assert _canonical_actions(text_result) == [
{
"domain": "seat",
"capability": "seat.start_dynamic_mode",
"target": {
"device": "seat",
"zone": "front_right",
"instance": "passenger",
},
"operation": "activate",
"parameters": {"mode_id": 1},
}
]
def test_text_and_speech_share_dynamic_mode_stop_command() -> None:
processor = UnderstandingTranscriptProcessor(REPOSITORY_ROOT)
understanding = processor.process("关闭木马模式").to_dict()
understanding["utterance_id"] = "mode-stop-equivalence"
with tempfile.TemporaryDirectory() as directory, patch.dict(
os.environ,
{
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false",
"COGNITIVE_AGENT_MODE_PROFILE_ROOT": str(Path(directory) / "modes"),
},
clear=False,
):
service = _mode_service(directory)
text_result = service.process(
"关闭木马模式",
provider="local",
conversation_id="conv-text-mode-stop",
)
speech_result = service.process(
understanding["clean_text"],
provider="local",
conversation_id="conv-speech-mode-stop",
_speech_understanding=understanding,
)
assert understanding["clean_text"] == "关闭木马模式。"
assert understanding["execution_eligible"] is True
assert text_result["status"] == speech_result["status"] == "succeeded"
assert _canonical_actions(text_result) == _canonical_actions(speech_result)
assert _canonical_actions(text_result)[0]["capability"] == "seat.stop_dynamic_mode"
assert _canonical_actions(text_result)[0]["parameters"] == {}
def test_explicit_incompatible_mode_target_fails_closed_for_both_inputs() -> None:
processor = UnderstandingTranscriptProcessor(REPOSITORY_ROOT)
understanding = processor.process("将主驾座椅调整为木马模式。").to_dict()
with tempfile.TemporaryDirectory() as directory, patch.dict(
os.environ,
{
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false",
"COGNITIVE_AGENT_MODE_PROFILE_ROOT": str(Path(directory) / "modes"),
},
clear=False,
):
service = _mode_service(directory)
text_result = service.process(
"将主驾座椅调整为木马模式。",
provider="local",
conversation_id="conv-text-mode-rejected",
)
assert understanding["semantic_status"] == "recognized_unsupported"
assert understanding["execution_eligible"] is False
assert text_result["status"] == "needs_confirmation"
assert _canonical_actions(text_result) == []
def test_text_and_speech_share_colloquial_multidomain_commands() -> None:
utterance = "先打开主驾座椅通风,再打开副驾座椅通风,最后把空调调成二十四度"
processor = UnderstandingTranscriptProcessor(REPOSITORY_ROOT)
understanding = processor.process(utterance).to_dict()
understanding["utterance_id"] = "multidomain-equivalence"
with tempfile.TemporaryDirectory() as directory, patch.dict(
os.environ,
{
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false",
},
clear=False,
):
service = _mode_service(directory)
text_result = service.process(
utterance,
provider="local",
conversation_id="conv-text-multidomain",
)
speech_result = service.process(
understanding["clean_text"],
provider="local",
conversation_id="conv-speech-multidomain",
_speech_understanding=understanding,
)
assert understanding["clean_text"] == (
"打开主驾座椅通风,然后打开副驾座椅通风,然后将空调设置为24度。"
)
assert understanding["execution_eligible"] is True
assert text_result["status"] == speech_result["status"] == "succeeded"
assert _sorted_canonical_actions(text_result) == _sorted_canonical_actions(
speech_result
)
assert {
action["capability"] for action in _canonical_actions(text_result)
} == {
"seat.set_driver_ventilation_native",
"seat.set_ventilation",
"climate.set_temperature",
}
+4 -3
View File
@@ -186,9 +186,10 @@ def test_frontend_two_stage_contract_and_source_badges() -> None:
assert '"cognitive-agent:validated-can-commands"' in app assert '"cognitive-agent:validated-can-commands"' in app
assert "本地规则命令已执行;云端补充失败" in app assert "本地规则命令已执行;云端补充失败" in app
assert 'class="v2-can-command-source"' in console assert 'class="v2-can-command-source"' in console
assert 'item.parseSource === "direct_control" ? "设备控件"' in console assert 'direct_control: "设备控件"' in console
assert 'item.parseSource === "saved_mode_command_flow" ? "模式卡片"' in console assert 'speech_semantic: "语音语义"' in console
assert 'item.parseSource === "local_rule" ? "本地规则" : "云端模型"' in console assert 'saved_mode_command_flow: "模式卡片"' in console
assert 'local_rule: "本地规则"' in console
assert 'item.deliveryLabel' in console assert 'item.deliveryLabel' in console
assert 'data-delivery-state="${escapeHtml(item.deliveryState)}"' in console assert 'data-delivery-state="${escapeHtml(item.deliveryState)}"' in console
assert '"cognitive-agent:validated-can-commands"' in console assert '"cognitive-agent:validated-can-commands"' in console