优化语音输入/普通文本输入
This commit is contained in:
@@ -416,6 +416,15 @@
|
||||
return [...unique.values()];
|
||||
}
|
||||
|
||||
function parsedCommandSourceLabel(source) {
|
||||
return {
|
||||
direct_control: "设备控件",
|
||||
saved_mode_command_flow: "模式卡片",
|
||||
local_rule: "本地规则",
|
||||
speech_semantic: "语音语义",
|
||||
}[source] || "云端模型";
|
||||
}
|
||||
|
||||
function renderParsedCanCommands(result, { message = "" } = {}) {
|
||||
const panel = $("#v2CanCommandPanel");
|
||||
const list = $("#v2CanCommandList");
|
||||
@@ -429,7 +438,7 @@
|
||||
<li data-can-channel="${escapeHtml(item.channel)}" data-delivery-state="${escapeHtml(item.deliveryState)}">
|
||||
<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 === "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>
|
||||
<i class="v2-can-command-category">${escapeHtml(item.category)}</i>
|
||||
</span>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<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-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 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>
|
||||
</head>
|
||||
<body class="console-v2">
|
||||
|
||||
@@ -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 RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
|
||||
const APP_SHELL = [
|
||||
@@ -10,11 +10,11 @@ const APP_SHELL = [
|
||||
"/gesture-control.js?v=android-pad-9",
|
||||
"/pwa.js?v=android-pad-3",
|
||||
"/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.js?v=gscu-tcp-41",
|
||||
"/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",
|
||||
"/assets/pets/self-extension-cat.png?v=3",
|
||||
"/assets/pets/self-extension-cat-entrance.png?v=3",
|
||||
|
||||
@@ -380,15 +380,21 @@
|
||||
const { input } = elements();
|
||||
if (!input) throw new Error("没有找到一句话编排输入框");
|
||||
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 : [];
|
||||
if (!cleanText || commands.length === 0) {
|
||||
if (!inputText || (commands.length === 0 && !peripheralEligible)) {
|
||||
window.dispatchEvent(new CustomEvent("cognitive-agent:speech-result", { detail: result }));
|
||||
return false;
|
||||
}
|
||||
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.speechExecutionEligible = String(result.execution_eligible === true);
|
||||
input.dataset.speechExecutionEligible = String(
|
||||
result.execution_eligible === true || peripheralEligible,
|
||||
);
|
||||
input.dataset.speechSemanticStatus = String(result.semantic_status || "");
|
||||
input.dataset.speechValue = input.value.trim();
|
||||
input.dataset.speechUtteranceId = String(result.utterance_id || "");
|
||||
@@ -423,6 +429,10 @@
|
||||
if (!applied) {
|
||||
setPhase("success", `未形成可执行控制指令 · ${latency} ms`);
|
||||
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") {
|
||||
setPhase("success", `已识别,但当前车辆能力不支持 · ${latency} ms`);
|
||||
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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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.]+)"
|
||||
)
|
||||
_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 = {
|
||||
"零": 0,
|
||||
"〇": 0,
|
||||
@@ -76,6 +82,24 @@ def _number(token: str) -> int | float | None:
|
||||
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:
|
||||
"""Return an explicitly spoken percentage/ratio/extreme position.
|
||||
|
||||
|
||||
@@ -9,7 +9,10 @@ from __future__ import annotations
|
||||
import re
|
||||
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 = {
|
||||
"全部": "all",
|
||||
@@ -177,7 +180,8 @@ _EXPLICIT_TARGET_PATTERNS = {
|
||||
re.IGNORECASE,
|
||||
),
|
||||
"seat": re.compile(
|
||||
r"主驾|驾驶位|驾驶席|司机位|左前(?:座|座椅|座位)?|"
|
||||
r"主驾|主驾驶(?:座|座椅|席)?|驾驶位|驾驶席|驾驶座(?:椅)?|"
|
||||
r"司机位|司机座(?:椅)?|左前(?:座|座椅|座位)?|"
|
||||
r"副驾(?:驶|位)?|乘客(?:位|座|座椅|座位)?|右前(?:座|座椅|座位)?|"
|
||||
r"前排|后排|左后|右后|所有座椅|全部座椅|全车座椅|"
|
||||
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}"
|
||||
continuation_index += 1
|
||||
index = continuation_index - 1
|
||||
if re.search(r"主驾|驾驶位|司机位|左前座椅", clause):
|
||||
if re.search(
|
||||
r"主驾|主驾驶(?:座|座椅|席)?|驾驶位|驾驶席|"
|
||||
r"驾驶座(?:椅)?|司机位|司机座(?:椅)?|左前座椅",
|
||||
clause,
|
||||
):
|
||||
inherited_driver_scope = True
|
||||
elif re.search(r"副驾|副驾驶|乘客|右前", clause):
|
||||
inherited_driver_scope = False
|
||||
@@ -592,7 +600,13 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
|
||||
re.IGNORECASE,
|
||||
):
|
||||
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):
|
||||
return []
|
||||
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]]:
|
||||
compact = re.sub(r"\s+", "", text)
|
||||
compact = re.sub(r"\s+", "", normalize_chinese_unit_values(text))
|
||||
seat_text = _scoped_text(
|
||||
compact,
|
||||
r"座椅|座位|靠背|椅背|坐盆|坐垫|腿托|脚托|按摩|记忆|预设|"
|
||||
@@ -981,7 +995,7 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
|
||||
|
||||
temperature = re.search(
|
||||
r"(?:空调(?:温度)?|温度)(?:都)?"
|
||||
r"(?:调整到|调整为|调到|调为|设为|设置为|调至|到|为)?"
|
||||
r"(?:调整到|调整为|调到|调成|调为|设到|设为|设置为|调至|到|为)?"
|
||||
r"(1[89]|2\d|3[0-2])(?:摄氏)?(?:℃|度)?(?![\d.])",
|
||||
climate_text,
|
||||
)
|
||||
@@ -1340,6 +1354,7 @@ def _drop_implicit_passenger_ch0_candidates(
|
||||
ambiguities.append(
|
||||
{
|
||||
"field": "seat.target_scope",
|
||||
"reason": "implicit_driver_default_unresolved",
|
||||
"question": (
|
||||
"未指定座椅目标时默认主驾 CH1;当前请求无法安全映射为已验证的主驾能力,"
|
||||
"且不会转入副驾 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}$")
|
||||
SEAT_SCOPE_PATTERN = re.compile(r"\bseats?\b|座椅|坐椅", re.IGNORECASE)
|
||||
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,
|
||||
)
|
||||
PASSENGER_SEAT_SCOPE_PATTERN = re.compile(
|
||||
@@ -2257,13 +2259,9 @@ class SeatDemoService:
|
||||
local_preview=local_preview,
|
||||
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)
|
||||
else:
|
||||
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)
|
||||
@@ -2282,6 +2280,19 @@ class SeatDemoService:
|
||||
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:
|
||||
parsed = self._mark_control_parse_source(
|
||||
parsed,
|
||||
@@ -3327,6 +3338,8 @@ class SeatDemoService:
|
||||
matched_modes: list[ModeProfile],
|
||||
context: dict[str, Any],
|
||||
vehicle_profile: VehicleProfile,
|
||||
*,
|
||||
utterance: str,
|
||||
) -> dict[str, Any]:
|
||||
if not matched_modes:
|
||||
return parsed
|
||||
@@ -3354,6 +3367,12 @@ class SeatDemoService:
|
||||
]
|
||||
return rejected
|
||||
|
||||
parsed = self._apply_implicit_mode_target_policy(
|
||||
parsed,
|
||||
matched_modes,
|
||||
utterance,
|
||||
)
|
||||
|
||||
safety_ambiguities = [
|
||||
item
|
||||
for item in parsed.get("ambiguities", [])
|
||||
@@ -3430,6 +3449,38 @@ class SeatDemoService:
|
||||
)
|
||||
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
|
||||
def _is_dynamic_mode_stop_request(utterance: str) -> bool:
|
||||
compact = re.sub(r"\s+", "", utterance)
|
||||
|
||||
@@ -165,6 +165,7 @@ class CapabilityResolver:
|
||||
*,
|
||||
target_scope: str | None,
|
||||
target_explicit: bool,
|
||||
action: str = "apply",
|
||||
) -> ModeResolution:
|
||||
matches = self.mode_profiles.match_text(text, vehicle_id="denza_n7")
|
||||
if not matches:
|
||||
@@ -172,7 +173,24 @@ class CapabilityResolver:
|
||||
if len(matches) > 1:
|
||||
return ModeResolution("ambiguous", None, ("multiple_mode_profiles_matched",))
|
||||
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:
|
||||
scopes = ",".join(sorted(operation_scopes))
|
||||
return ModeResolution(
|
||||
@@ -183,22 +201,28 @@ class CapabilityResolver:
|
||||
|
||||
payloads: list[dict[str, Any]] = []
|
||||
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(
|
||||
domain=operation.domain,
|
||||
semantic_property="",
|
||||
target_scope=operation.target_scope,
|
||||
target_device=None,
|
||||
operation=operation.operation,
|
||||
parameters=operation.parameters,
|
||||
operation="set" if action == "stop" else operation.operation,
|
||||
parameters=parameters,
|
||||
source_text=text,
|
||||
preferred_capability=operation.capability,
|
||||
preferred_capability=capability,
|
||||
)
|
||||
if resolution.status != "executable" or resolution.executor_payload is None:
|
||||
issues.extend(resolution.issues or ("mode_operation_not_executable",))
|
||||
continue
|
||||
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("executable", profile, executor_payloads=tuple(payloads))
|
||||
|
||||
|
||||
@@ -9,7 +9,10 @@ from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
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.infrastructure.speech.capability import CapabilityResolver
|
||||
from cognitive_agent.infrastructure.speech.contracts import (
|
||||
@@ -858,11 +861,13 @@ class UnderstandingTranscriptProcessor:
|
||||
|
||||
mode_profile = self.resolver.match_mode(text)
|
||||
if mode_profile is not None:
|
||||
mode_operation = "stop" if action == "off" else "apply"
|
||||
mode_started = time.perf_counter()
|
||||
mode_resolution = self.resolver.resolve_mode(
|
||||
text,
|
||||
target_scope=scope,
|
||||
target_explicit=explicit_scope,
|
||||
action=mode_operation,
|
||||
)
|
||||
resolution_ms += (time.perf_counter() - mode_started) * 1000
|
||||
mode_issues = tuple(mode_resolution.issues)
|
||||
@@ -873,7 +878,7 @@ class UnderstandingTranscriptProcessor:
|
||||
target_scope=command_scope,
|
||||
target_device="seat",
|
||||
property_name="seat.mode",
|
||||
operation="apply",
|
||||
operation=mode_operation,
|
||||
parameters={"mode_profile_id": mode_profile.id},
|
||||
value=mode_profile.name,
|
||||
unit=None,
|
||||
@@ -954,6 +959,14 @@ class UnderstandingTranscriptProcessor:
|
||||
parameters["state"] = action if action in {"on", "off"} else "on"
|
||||
if value is not None:
|
||||
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:
|
||||
local = ("seat_thermal_action_incomplete",)
|
||||
commands.append(
|
||||
@@ -1317,7 +1330,7 @@ class UnderstandingTranscriptProcessor:
|
||||
|
||||
@staticmethod
|
||||
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)
|
||||
if match:
|
||||
value = float(match.group(1))
|
||||
@@ -1415,6 +1428,8 @@ class UnderstandingTranscriptProcessor:
|
||||
return f"将{self._scoped_target(command, feature)}设置为{level}"
|
||||
return f"{'打开' if command.operation == 'on' else '关闭'}{self._scoped_target(command, feature)}"
|
||||
if prop == "seat.mode":
|
||||
if command.operation == "stop":
|
||||
return f"关闭{command.value}"
|
||||
return f"将{self._scoped_target(command, '座椅')}调整为{command.value}"
|
||||
if prop == "seat.backrest_recline":
|
||||
direction = (
|
||||
|
||||
@@ -212,6 +212,9 @@ ACTION_ALIASES: dict[str, tuple[str, ...]] = {
|
||||
"调成",
|
||||
"调为",
|
||||
"变成",
|
||||
"进入",
|
||||
"切换到",
|
||||
"切换为",
|
||||
"设为",
|
||||
"设成",
|
||||
"设到",
|
||||
|
||||
@@ -532,6 +532,8 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
speech_utterance_id = str(
|
||||
payload.get("speech_utterance_id", "")
|
||||
).strip()
|
||||
speech_understanding: dict[str, object] | None = None
|
||||
speech_peripheral_route = False
|
||||
if speech_utterance_id:
|
||||
if path != "/api/requests":
|
||||
raise ValueError(
|
||||
@@ -540,13 +542,28 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
speech_understanding = self.speech.cached_result(
|
||||
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(
|
||||
"speech_text_changed",
|
||||
"语音文本已被修改,请确认修改后的内容再执行。",
|
||||
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(
|
||||
"speech_not_executable",
|
||||
"语音意图尚未通过能力或参数校验,已拒绝执行。",
|
||||
@@ -554,6 +571,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
)
|
||||
provider = "local"
|
||||
common_arguments["provider"] = provider
|
||||
if not speech_peripheral_route:
|
||||
common_arguments["_speech_understanding"] = speech_understanding
|
||||
if (
|
||||
path != "/api/can/commands"
|
||||
@@ -620,6 +638,14 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
)
|
||||
else:
|
||||
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)
|
||||
self.vehicle_display_sync.update_task(
|
||||
status=str(result.get("status", "succeeded")),
|
||||
@@ -815,6 +841,54 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
)
|
||||
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
|
||||
def _residual_has_vehicle_language(utterance: str) -> bool:
|
||||
"""Recognize only obvious vehicle-domain text in a claimed mixed request."""
|
||||
@@ -2206,6 +2280,14 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
self.rfile.read(content_length),
|
||||
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)
|
||||
except SpeechRecognitionError as exc:
|
||||
self._json(
|
||||
|
||||
@@ -624,7 +624,7 @@ operations:
|
||||
) as response:
|
||||
gesture_script = response.read().decode("utf-8")
|
||||
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:
|
||||
console_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
@@ -632,7 +632,7 @@ operations:
|
||||
) as response:
|
||||
console_styles = response.read().decode("utf-8")
|
||||
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:
|
||||
speech_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
@@ -685,9 +685,9 @@ operations:
|
||||
self.assertIn('href="/styles.css?v=dual-seat-21"', 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=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('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('id="speechDictationButton"', html)
|
||||
self.assertIn('class="speech-waveform"', html)
|
||||
@@ -959,9 +959,9 @@ operations:
|
||||
self.assertIn("CAN 指令已提交,等待车辆状态确认", console_script)
|
||||
self.assertIn('canvas.addEventListener("webglcontextlost"', 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('"/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-audio-worklet.js?v=sensevoice-4"', service_worker)
|
||||
self.assertIn('fetch("/api/speech/transcribe"', speech_script)
|
||||
@@ -979,6 +979,9 @@ operations:
|
||||
self.assertIn("speechExecutionEligible", app_script)
|
||||
self.assertIn("speech_utterance_id", app_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(".speech-dictation.is-listening", speech_styles)
|
||||
@@ -1741,6 +1744,143 @@ operations:
|
||||
self.assertEqual(operation["domain"], "self_extension")
|
||||
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:
|
||||
original_extension = SeatDemoHandler.extension
|
||||
original_nfc = SeatDemoHandler.nfc
|
||||
|
||||
+4
-1
@@ -58,5 +58,8 @@
|
||||
{"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":"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}
|
||||
]
|
||||
|
||||
@@ -47,7 +47,7 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None:
|
||||
):
|
||||
assert asset in html
|
||||
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:
|
||||
|
||||
@@ -139,4 +139,5 @@ def test_main_parsed_control_panel_renders_validated_infrared_operations() -> No
|
||||
assert 'frameLabel: "GSCU1B TCP"' in console
|
||||
assert "已解析控制指令" in console
|
||||
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:
|
||||
console = source("console-v2.js")
|
||||
styles = source("console-v2.css")
|
||||
|
||||
@@ -148,7 +148,7 @@ class SpeechCapabilityAlignmentTests(unittest.TestCase):
|
||||
"passenger",
|
||||
"seat.ventilation",
|
||||
"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:
|
||||
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):
|
||||
result = self.processor.process(text)
|
||||
self.assertTrue(result.execution_eligible)
|
||||
self.assertEqual(len(result.commands), 1)
|
||||
self.assertEqual(result.commands[0].target_scope, target)
|
||||
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("主驾座椅通风开到一档")
|
||||
self.assertEqual(explicit_level.commands[0].parameters["level"], 1)
|
||||
@@ -455,7 +475,7 @@ class SpeechCapabilityAlignmentTests(unittest.TestCase):
|
||||
)
|
||||
expected = (
|
||||
("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}),
|
||||
)
|
||||
for text in cases:
|
||||
|
||||
@@ -15,6 +15,44 @@ from cognitive_agent.infrastructure.speech.understanding import (
|
||||
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(
|
||||
("utterance", "capability", "target", "parameters"),
|
||||
(
|
||||
@@ -194,3 +232,157 @@ def test_every_registered_native_runtime_capability_keeps_native_bridge_binding(
|
||||
assert [
|
||||
action["capability"] for action in parsed["native_can_actions"]
|
||||
] == [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",
|
||||
}
|
||||
|
||||
@@ -186,9 +186,10 @@ def test_frontend_two_stage_contract_and_source_badges() -> None:
|
||||
assert '"cognitive-agent:validated-can-commands"' in app
|
||||
assert "本地规则命令已执行;云端补充失败" in app
|
||||
assert 'class="v2-can-command-source"' in console
|
||||
assert 'item.parseSource === "direct_control" ? "设备控件"' in console
|
||||
assert 'item.parseSource === "saved_mode_command_flow" ? "模式卡片"' in console
|
||||
assert 'item.parseSource === "local_rule" ? "本地规则" : "云端模型"' in console
|
||||
assert 'direct_control: "设备控件"' in console
|
||||
assert 'speech_semantic: "语音语义"' in console
|
||||
assert 'saved_mode_command_flow: "模式卡片"' in console
|
||||
assert 'local_rule: "本地规则"' in console
|
||||
assert 'item.deliveryLabel' in console
|
||||
assert 'data-delivery-state="${escapeHtml(item.deliveryState)}"' in console
|
||||
assert '"cognitive-agent:validated-can-commands"' in console
|
||||
|
||||
Reference in New Issue
Block a user