merge: integrate remote speech capabilities into feature/N7Web

This commit is contained in:
minghanchen
2026-09-01 17:01:50 +08:00
39 changed files with 6869 additions and 52 deletions
+1
View File
@@ -8,6 +8,7 @@ artifacts/mode-profiles/
artifacts/server/
artifacts/tmp/
artifacts/self-repair/
artifacts/models/
artifacts/*.log
tmp/*.log
.codex_tmp/
+23 -1
View File
@@ -96,10 +96,32 @@ make dev
浏览器访问 `http://127.0.0.1:8000`。设置 `OPENAI_API_KEY` 后,GPT Structured Outputs 会基于各领域的 `config/capabilities.yaml` 和当前会话状态直接生成通用能力请求;后端只做结构校验、领域路由和确定性执行适配,不用本地语义规则改写 GPT 动作。本地解析器用于未配置 GPT,或 GPT/`auto` 模式因 API Key、模型权限、网络或结构化响应异常而失败时的安全降级;结果会明确标记 `local_rules_fallback`。模型由 `OPENAI_MODEL=gpt-5.6-sol` 指定;兼容 OpenAI Responses API 的服务可通过 `OPENAI_BASE_URL` 指定。无论使用哪种解析器,配置校验、CAN 编译和虚拟执行均为同一确定性路径。
### 一句话、快捷模式与手势控制
### 一句话、语音听写、快捷模式与手势控制
首页左侧提供一句话控制,可同时描述座椅、空调、天幕和氛围灯目标;Enter 直接执行,Shift+Enter 换行。观影、午休和零重力是可直接调用的快捷模式,“保存当前”可把当前副驾驶姿态保存为命名模式。复杂跨域自然语言优先使用 GPT 解析,本地规则用于已明确覆盖的离线指令。
输入框右侧的麦克风按钮提供本地中文语音听写:优先选择 `TAKSTAR V2` 输入设备,`AudioWorklet` 在音频线程采集(旧浏览器才降级到 `ScriptProcessor`),浏览器端静音检测负责自动停录,16 kHz PCM WAV 发送到同源 `/api/speech/transcribe`。服务端再使用 Silero VAD 做权威分段并保留句首/句尾保护区,SenseVoiceSmall 只负责声学识别;统一 `TranscriptAssembler` 负责 final commit、顺序、重复回调和保守的相邻重叠消除。
声学 `raw_text` 永远保留。独立的 `UnderstandingTranscriptProcessor` 根据工程能力目录生成 `clean_text`、结构化 `commands`、`cancelled_fragments` 和 `uncertain`,区分“然后”的正常多指令与“不对、改成、算了”等修正/撤销。无关聊天与否定不会激活控制;超出工程参数范围或表达残缺的结果会显示但禁止直接提交,用户修改确认后才能执行。听写本身不自动执行控制命令,也不保存录音。首次部署:
```bash
python -m pip install -e ".[speech]"
python tools/install_sensevoice_model.py
```
安装脚本同时校验并部署 `artifacts/models/silero-vad/silero_vad.onnx`。默认使用完整精度 SenseVoice、`language=zh`、ITN 和最多 8 个 CPU 线程;VAD 针对短车控语句使用阈值 `0.45`、最短语音 `0.16s`、结束静音 `0.65s`、最长分段 `12s`、前置 `0.25s`、后置 `0.35s`。可通过 `SPEECH_MODEL_*`、`SPEECH_LANGUAGE`、`SPEECH_USE_ITN`、`SPEECH_NUM_THREADS`、`SPEECH_PROVIDER` 与 `SPEECH_VAD_*` 覆盖。
当前 sherpa-onnx 版本支持 SenseVoice 的 `hr_lexicon`/`hr_rule_fsts`,只有同时提供经过验证的文件时才会启用:`SPEECH_HR_LEXICON` 和 `SPEECH_HR_RULE_FSTS`。未对非 transducer 的 SenseVoice 使用 hotword decoding。可插拔 `RecoveryRecognizer` 默认关闭,只为将来让 uncertain case 使用二次识别,不会下载大型模型。
诊断时设置 `SPEECH_DEBUG=1` 输出按 `utteranceId`、segment 边界、raw final、拼接前后、理解结果、Intent 与阶段耗时组织的 JSON 日志;只有额外设置 `SPEECH_DEBUG_RESPONSE=1` 才把 trace 返回浏览器。前端访问参数 `?speechDebug=1`(或 localStorage 同名值)启用控制台日志,生产默认不打印。回归与指标基准:
```bash
pytest tests/unit/test_speech_transcription.py tests/unit/test_transcript_assembler.py
python tools/benchmark_speech_pipeline.py --audio-dir artifacts/tmp/speech-regression-audio
```
麦克风仅可用于安全来源:本机使用 `http://127.0.0.1:8000`,远程使用 HTTPS。
顶部“手势控制”打开同页抽屉。浏览器在本机完成 MediaPipe 推理,识别结果仍通过同一自然语言编排、安全校验与执行链路处理。运行必需资源已经纳入本工程:
```text
+10
View File
@@ -6,6 +6,7 @@ Web 控制台是车机端的远程自编排与同步显示界面。网页保留
- 自编排和自扩展合并为一个实时界面,登录身份固定为 `user`,不再提供模块切换。
- 网页端可输入一句话指令或点击预设模式,车机端同样可发起任务;两端任务与结果统一同步。
- 一句话输入框支持麦克风听写,优先采集 `TAKSTAR V2`;AudioWorklet 采集、浏览器端静音停录、服务端 Silero VAD、SenseVoiceSmall、幂等 TranscriptAssembler 和结构化理解层组成完整链路。只有生成 commands 的结果才回填;无关内容不覆盖输入框,uncertain 结果需人工修改后才能执行。
- 整车数字孪生位于工作区中央,点击或悬停热点可查看车窗、遮阳帘、主/副驾座椅、阅读灯、氛围灯、前大灯和空调的完整状态。
- 车端设备状态与外设能力板块均可独立隐藏或恢复,面板状态在当前浏览器会话中保留。
- USB 外设接入后同步显示 GSCU 链路、外设名称、类型、已学习能力和最近一次跨设备联动。
@@ -19,6 +20,15 @@ UI 只展示可审计的“决策摘要”和“处理轨迹”,不展示模
make dev
```
语音听写首次运行前安装运行时、官方完整精度模型和 Silero VAD:
```bash
python -m pip install -e ".[speech]"
python tools/install_sensevoice_model.py
```
录音只用于当前请求,不落盘;`raw_text`、`clean_text` 与结构化 Intent 分字段返回。远程浏览器必须通过 HTTPS 才能访问麦克风。诊断可设置后端 `SPEECH_DEBUG=1` 并用 `?speechDebug=1` 打开前端日志,默认关闭。
Android PAD/模拟器联调使用:
```powershell
+44 -5
View File
@@ -7556,18 +7556,21 @@ function naturalLanguageRequestBody(
{
executionMode = state.executionMode,
clientType = "web_console",
provider = state.provider,
speechUtteranceId = null,
} = {},
) {
const driverOnlyCh1 = isDriverOnlyCh1Runtime();
return {
utterance,
client_type: clientType,
provider: state.provider,
provider,
conversation_id: state.conversationId,
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
execution_mode: executionMode,
control_transport: state.controlTransport,
...(state.provider === "openai" ? { reasoning_effort: state.reasoningEffort } : {}),
...(provider === "openai" ? { reasoning_effort: state.reasoningEffort } : {}),
...(speechUtteranceId ? { speech_utterance_id: speechUtteranceId } : {}),
...(executionMode === "hardware"
&& state.controlTransport !== "wireless_mqtt"
&& !driverOnlyCh1
@@ -7584,6 +7587,8 @@ async function requestNaturalLanguageControl(
{
executionMode = state.executionMode,
clientType = "web_console",
provider = state.provider,
speechUtteranceId = null,
previewOnly = false,
onControlPhaseEvent = null,
onPreview = null,
@@ -7593,8 +7598,10 @@ async function requestNaturalLanguageControl(
const requestBody = naturalLanguageRequestBody(utterance, {
executionMode,
clientType,
provider,
speechUtteranceId,
});
const usePreview = previewOnly || state.provider === "openai";
const usePreview = previewOnly || provider === "openai";
if (!usePreview) {
const payload = await postControlPhase(
"/api/requests",
@@ -7623,7 +7630,7 @@ async function requestNaturalLanguageControl(
`/api/requests/${preview.request_id}/supplement`,
{
client_type: clientType,
provider: state.provider,
provider,
reasoning_effort: state.reasoningEffort,
...(executionMode === "hardware"
&& state.controlTransport !== "wireless_mqtt"
@@ -7712,7 +7719,14 @@ async function executeCanCommands(canCommands, { deviceName = "车辆设备" } =
async function submitRequest(event) {
event.preventDefault();
const utterance = $("#utterance").value.trim();
const utteranceInput = $("#utterance");
const utterance = utteranceInput.value.trim();
const speechSubmission = (
utteranceInput.dataset.speechExecutionEligible === "true"
&& utteranceInput.dataset.speechValue === utterance
&& Boolean(utteranceInput.dataset.speechUtteranceId)
);
const requestProvider = speechSubmission ? "local" : state.provider;
const submittedModeProfileId = state.pendingModeProfileId;
state.pendingModeProfileId = null;
if (!utterance) {
@@ -7720,6 +7734,20 @@ async function submitRequest(event) {
showToast("请输入车舱控制请求");
return;
}
if (
utteranceInput.dataset.speechExecutionEligible === "false"
&& utteranceInput.dataset.speechValue === utterance
) {
if (submittedModeProfileId) setModeExecutionState(submittedModeProfileId, "failed");
const speechStatus = utteranceInput.dataset.speechSemanticStatus;
const message = speechStatus === "recognized_unsupported"
? "语音意图已识别,但当前车辆能力不支持执行"
: speechStatus === "incomplete"
? "语音控制参数不完整,请补充后再执行"
: "语音结果存在歧义,请修改确认后再执行";
showToast(message);
return;
}
const driverOnlyCh1 = isDriverOnlyCh1Runtime();
if (state.executionMode === "hardware"
&& state.controlTransport !== "wireless_mqtt"
@@ -7757,6 +7785,10 @@ async function submitRequest(event) {
const { payload, supplementWarning } = await requestNaturalLanguageControl(
utterance,
{
provider: requestProvider,
speechUtteranceId: speechSubmission
? utteranceInput.dataset.speechUtteranceId
: null,
onControlPhaseEvent,
onPreview: (preview) => {
renderResult(preview);
@@ -7771,6 +7803,13 @@ async function submitRequest(event) {
if (!payload) return;
renderResult(payload);
completedResult = payload;
if (speechSubmission) {
delete utteranceInput.dataset.speechUtteranceId;
delete utteranceInput.dataset.speechExecutionEligible;
delete utteranceInput.dataset.speechSemanticStatus;
delete utteranceInput.dataset.speechValue;
delete utteranceInput.dataset.speechUncertain;
}
const peripheralRoute = String(payload.orchestration_route || "")
.startsWith("nfc_peripheral");
if (state.executionMode === "hardware" && !peripheralRoute) {
+8
View File
@@ -12,12 +12,14 @@
<link rel="stylesheet" href="/styles.css?v=dual-seat-16" />
<link rel="stylesheet" href="/console-v2.css?v=delivery-state-43" />
<link rel="stylesheet" href="/self-extension.css?v=gscu-tcp-39" />
<link rel="stylesheet" href="/speech-input.css?v=sensevoice-4" />
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
<script src="/vendor/three/0.147.0/three.min.js" defer></script>
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
<script src="/network.js?v=android-pad-2" defer></script>
<script src="/app.js?v=dual-seat-109" defer></script>
<script src="/speech-input.js?v=sensevoice-6" 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-50" defer></script>
@@ -73,6 +75,12 @@
</div>
<div class="command-composer">
<textarea id="utterance" name="utterance" maxlength="1000" placeholder="例如:有点热,把副驾驶调舒服些,再把天幕调暗">进入观影状态:空调24度、风量2档,天幕调暗,氛围灯换成暖橙色</textarea>
<button class="speech-dictation" id="speechDictationButton" type="button" aria-label="开始语音听写" aria-pressed="false" title="语音听写(自动识别说话结束)">
<i class="speech-mic-icon" data-lucide="mic" aria-hidden="true"></i>
<span class="speech-waveform" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i></span>
<span class="speech-visually-hidden">开始语音听写</span>
</button>
<span class="speech-status" id="speechDictationStatus" role="status" aria-live="polite"></span>
<span class="composer-hint">Enter 执行 · Shift + Enter 换行</span>
</div>
+4 -1
View File
@@ -1,4 +1,4 @@
const CACHE_VERSION = "cognitive-agent-pad-v220";
const CACHE_VERSION = "cognitive-agent-pad-v221";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [
@@ -13,6 +13,9 @@ const APP_SHELL = [
"/console-v2.js?v=delivery-state-50",
"/self-extension.css?v=gscu-tcp-39",
"/self-extension.js?v=gscu-tcp-40",
"/speech-input.css?v=sensevoice-4",
"/speech-input.js?v=sensevoice-6",
"/speech-audio-worklet.js?v=sensevoice-4",
"/assets/pets/self-extension-cat.png?v=3",
"/assets/pets/self-extension-cat-entrance.png?v=3",
"/manifest.webmanifest",
+29
View File
@@ -0,0 +1,29 @@
class SpeechCaptureProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.frameSize = 2048;
this.frame = new Float32Array(this.frameSize);
this.offset = 0;
}
process(inputs) {
const input = inputs[0]?.[0];
if (!input) return true;
let inputOffset = 0;
while (inputOffset < input.length) {
const available = Math.min(this.frameSize - this.offset, input.length - inputOffset);
this.frame.set(input.subarray(inputOffset, inputOffset + available), this.offset);
this.offset += available;
inputOffset += available;
if (this.offset === this.frameSize) {
const ready = this.frame;
this.port.postMessage(ready, [ready.buffer]);
this.frame = new Float32Array(this.frameSize);
this.offset = 0;
}
}
return true;
}
}
registerProcessor("speech-capture-processor", SpeechCaptureProcessor);
+257
View File
@@ -0,0 +1,257 @@
.speech-visually-hidden {
width: 1px;
height: 1px;
position: absolute;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
/* Keep all composer actions on one calm, optically aligned rail. */
.console-v2 .v2-request-form .speech-dictation {
width: 36px;
height: 36px;
min-height: 36px;
position: absolute;
top: 50%;
right: 56px;
bottom: auto;
z-index: 13;
display: grid;
place-items: center;
margin: 0;
padding: 0;
color: #284647;
background: transparent;
border: 0;
border-radius: 50%;
box-shadow: none;
cursor: pointer;
transform: translateY(-50%);
transition: color 160ms ease, background 160ms ease, box-shadow 160ms ease,
transform 160ms ease;
}
.console-v2 .v2-request-form .speech-dictation:hover:not(:disabled),
.console-v2 .v2-request-form .speech-dictation:focus-visible {
color: #08766e;
background: #eaf5f3;
box-shadow: 0 0 0 3px rgba(8, 118, 110, 0.08);
transform: translateY(-50%) scale(1.05);
}
.console-v2 .v2-request-form .speech-dictation:focus-visible {
outline: 2px solid rgba(8, 118, 110, 0.48);
outline-offset: 2px;
}
.console-v2 .v2-request-form .speech-dictation:disabled {
color: #9aacaa;
cursor: not-allowed;
opacity: 0.56;
}
.console-v2 .v2-request-form .speech-dictation .speech-mic-icon {
width: 18px;
height: 18px;
stroke-width: 2;
}
.console-v2 .v2-request-form .speech-waveform {
height: 16px;
display: none;
align-items: center;
justify-content: center;
gap: 2px;
}
.console-v2 .v2-request-form .speech-waveform i {
width: 2px;
height: 5px;
display: block;
background: currentColor;
border-radius: 999px;
}
.console-v2 .v2-request-form .speech-waveform i:nth-child(2),
.console-v2 .v2-request-form .speech-waveform i:nth-child(4) {
height: 10px;
}
.console-v2 .v2-request-form .speech-waveform i:nth-child(3) {
height: 15px;
}
.console-v2 .v2-request-form .speech-dictation.is-listening,
.console-v2 .v2-request-form .speech-dictation.is-listening:hover:not(:disabled),
.console-v2 .v2-request-form .speech-dictation.is-listening:focus-visible {
color: #fff;
background: #132c2c;
box-shadow: 0 0 0 5px rgba(19, 44, 44, 0.09), 0 7px 18px rgba(17, 47, 46, 0.2);
}
.console-v2 .v2-request-form .speech-dictation.is-listening .speech-mic-icon,
.console-v2 .v2-request-form .speech-dictation.is-processing .speech-mic-icon {
display: none;
}
.console-v2 .v2-request-form .speech-dictation.is-listening .speech-waveform,
.console-v2 .v2-request-form .speech-dictation.is-processing .speech-waveform {
display: flex;
}
.console-v2 .v2-request-form .speech-dictation.is-listening .speech-waveform i {
animation: speech-wave 760ms ease-in-out infinite alternate;
}
.console-v2 .v2-request-form .speech-dictation.is-listening .speech-waveform i:nth-child(2) {
animation-delay: -420ms;
}
.console-v2 .v2-request-form .speech-dictation.is-listening .speech-waveform i:nth-child(3) {
animation-delay: -180ms;
}
.console-v2 .v2-request-form .speech-dictation.is-listening .speech-waveform i:nth-child(4) {
animation-delay: -560ms;
}
.console-v2 .v2-request-form .speech-dictation.is-processing {
color: #08766e;
background: #e5f3f1;
}
.console-v2 .v2-request-form .speech-dictation.is-processing .speech-waveform {
animation: speech-processing-breathe 900ms ease-in-out infinite alternate;
}
.console-v2 .v2-request-form .speech-status {
min-height: 16px;
position: absolute;
right: 140px;
bottom: 10px;
left: 194px;
z-index: 12;
overflow: hidden;
color: #5f7474;
font-size: 10px;
font-weight: 650;
line-height: 16px;
letter-spacing: 0.01em;
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none;
}
.console-v2 .v2-request-form .command-composer[data-speech-state="listening"] {
border-color: rgba(19, 76, 73, 0.36);
box-shadow: 0 12px 35px rgba(25, 72, 70, 0.1), inset 0 0 0 1px rgba(19, 76, 73, 0.08);
}
.console-v2 .v2-request-form .command-composer[data-speech-state="listening"] .speech-status {
color: #183e3d;
}
.console-v2 .v2-request-form .command-composer[data-speech-state="processing"] .speech-status {
color: #39706d;
}
.console-v2 .v2-request-form .command-composer[data-speech-state="success"] .speech-status {
color: #08766e;
}
.console-v2 .v2-request-form #utterance[data-speech-uncertain="true"] {
background: linear-gradient(90deg, rgba(201, 132, 31, 0.055), transparent 42%);
box-shadow: inset 3px 0 0 rgba(201, 132, 31, 0.58);
}
.console-v2 .v2-request-form .command-composer[data-speech-state="error"] .speech-status {
color: #b54b42;
}
.console-v2 .v2-request-form .command-composer:not([data-speech-state="idle"]) .composer-hint {
opacity: 0;
}
.console-v2 .v2-request-form #utterance,
.console-v2.v2-interactive-sync .v2-request-form #utterance,
.console-v2.v2-passive-display.v2-interactive-sync .v2-request-form #utterance {
padding-right: 142px;
}
.console-v2 .v2-request-form .context-reset,
.console-v2.v2-interactive-sync .v2-request-form .context-reset,
.console-v2.v2-passive-display.v2-interactive-sync .v2-request-form .context-reset {
width: 30px;
height: 30px;
top: 50%;
right: 98px;
bottom: auto;
color: #75908e;
border-radius: 50%;
transform: translateY(-50%);
}
.console-v2 .v2-request-form .context-reset:hover,
.console-v2 .v2-request-form .context-reset:focus-visible {
color: #08766e;
background: #edf6f4;
}
.console-v2 .v2-request-form .composer-hint,
.console-v2.v2-interactive-sync .v2-request-form .composer-hint,
.console-v2.v2-passive-display.v2-interactive-sync .v2-request-form .composer-hint {
right: 140px;
}
@keyframes speech-wave {
from { transform: scaleY(0.42); }
to { transform: scaleY(1); }
}
@keyframes speech-processing-breathe {
from { opacity: 0.42; transform: scaleX(0.78); }
to { opacity: 1; transform: scaleX(1); }
}
@media (max-width: 720px) {
.console-v2 .v2-request-form .speech-status {
top: 8px;
right: 140px;
bottom: auto;
left: 14px;
font-size: 9px;
}
.console-v2 .v2-request-form .command-composer:not([data-speech-state="idle"]) #utterance {
padding-top: 28px;
}
}
@media (max-width: 460px) {
.console-v2 .v2-request-form #utterance,
.console-v2.v2-interactive-sync .v2-request-form #utterance,
.console-v2.v2-passive-display.v2-interactive-sync .v2-request-form #utterance {
padding-right: 132px;
}
.console-v2 .v2-request-form .speech-dictation {
right: 52px;
}
.console-v2 .v2-request-form .context-reset,
.console-v2.v2-interactive-sync .v2-request-form .context-reset,
.console-v2.v2-passive-display.v2-interactive-sync .v2-request-form .context-reset {
right: 92px;
}
}
@media (prefers-reduced-motion: reduce) {
.console-v2 .v2-request-form .speech-dictation,
.console-v2 .v2-request-form .speech-waveform,
.console-v2 .v2-request-form .speech-waveform i {
animation: none !important;
transition: none;
}
}
+527
View File
@@ -0,0 +1,527 @@
(() => {
"use strict";
const TARGET_SAMPLE_RATE = 16000;
const MAX_RECORDING_MS = 28000;
const NO_SPEECH_TIMEOUT_MS = 8000;
const ENDPOINT_SILENCE_MS = 1050;
const STATUS_RESET_MS = 5000;
const MICROPHONE_CONNECT_TIMEOUT_MS = 12000;
const PREFERRED_MICROPHONE = /TAKSTAR\s*V2/i;
const DEBUG = new URLSearchParams(window.location.search).get("speechDebug") === "1"
|| window.localStorage.getItem("speechDebug") === "1";
const speech = {
phase: "idle",
backendReady: false,
stream: null,
context: null,
contextPromise: null,
workletReady: false,
workletFailed: false,
source: null,
captureNode: null,
silentGain: null,
chunks: [],
totalSamples: 0,
sampleRate: 0,
startedAt: 0,
speechStartSample: null,
lastVoiceSample: null,
consecutiveVoiceSamples: 0,
noiseFloor: 0.003,
statusTimer: null,
generation: 0,
utteranceId: "",
finishPromise: null,
applyingResult: false,
lastResult: null,
};
const elements = () => ({
button: document.querySelector("#speechDictationButton"),
composer: document.querySelector("#speechDictationButton")?.closest(".command-composer"),
input: document.querySelector("#utterance"),
status: document.querySelector("#speechDictationStatus"),
});
function debug(stage, details = {}) {
if (!DEBUG) return;
console.debug("[speech]", { utteranceId: speech.utteranceId, stage, ...details });
}
function announce(message) {
const notify = window.CognitiveAgentConsole?.notify;
if (typeof notify === "function") notify(message);
}
function setPhase(phase, message = "") {
speech.phase = phase;
const { button, composer, status } = elements();
if (!button || !composer || !status) return;
window.clearTimeout(speech.statusTimer);
composer.dataset.speechState = phase;
status.textContent = message;
button.classList.toggle("is-listening", phase === "listening");
button.classList.toggle("is-processing", phase === "processing");
button.disabled = phase === "processing" || (!speech.backendReady && phase !== "listening");
button.setAttribute("aria-pressed", String(phase === "listening"));
button.setAttribute(
"aria-label",
phase === "listening" ? "结束语音听写" : phase === "processing" ? "正在转写语音" : "开始语音听写",
);
button.title = phase === "listening"
? "点击结束听写"
: speech.backendReady
? "语音听写(自动识别说话结束)"
: "语音识别模型未就绪";
if (["success", "error"].includes(phase)) {
speech.statusTimer = window.setTimeout(() => setPhase("idle"), STATUS_RESET_MS);
}
}
function microphoneErrorMessage(error) {
switch (error?.name) {
case "NotAllowedError":
return "未获得麦克风权限,请在浏览器地址栏允许访问麦克风。";
case "NotFoundError":
return "没有找到可用麦克风,请检查 TAKSTAR V2 接收器。";
case "NotReadableError":
return "麦克风正被其他程序占用,请关闭占用程序后重试。";
case "OverconstrainedError":
return "TAKSTAR V2 当前不可用,请重新连接接收器。";
case "SecurityError":
return "当前页面不允许访问麦克风,请使用 HTTPS 或本机地址。";
case "TimeoutError":
return "等待麦克风授权超时,请允许访问后重试。";
default:
return "无法启动麦克风,请检查设备连接和浏览器权限。";
}
}
async function getUserMediaWithTimeout(constraints) {
let expired = false;
let timer = 0;
const request = navigator.mediaDevices.getUserMedia(constraints);
const timeout = new Promise((_, reject) => {
timer = window.setTimeout(() => {
expired = true;
reject(new DOMException("microphone permission timed out", "TimeoutError"));
}, MICROPHONE_CONNECT_TIMEOUT_MS);
});
request.then((lateStream) => {
if (expired) lateStream.getTracks().forEach((track) => track.stop());
}).catch(() => {});
try {
return await Promise.race([request, timeout]);
} finally {
window.clearTimeout(timer);
}
}
async function preferredMicrophoneStream() {
const constraints = {
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
};
let stream = await getUserMediaWithTimeout({ audio: constraints, video: false });
const currentTrack = stream.getAudioTracks()[0];
const devices = await navigator.mediaDevices.enumerateDevices();
const preferred = devices.find(
(device) => device.kind === "audioinput" && PREFERRED_MICROPHONE.test(device.label),
);
if (preferred && preferred.deviceId !== currentTrack?.getSettings?.().deviceId) {
stream.getTracks().forEach((track) => track.stop());
stream = await getUserMediaWithTimeout({
audio: { ...constraints, deviceId: { exact: preferred.deviceId } },
video: false,
});
}
return stream;
}
async function ensureAudioContext() {
if (speech.context && speech.context.state !== "closed") {
await speech.context.resume();
return speech.context;
}
if (speech.contextPromise) return speech.contextPromise;
speech.contextPromise = (async () => {
const AudioContext = window.AudioContext || window.webkitAudioContext;
const context = new AudioContext({ latencyHint: "interactive" });
await context.resume();
if (context.audioWorklet && window.AudioWorkletNode) {
try {
await context.audioWorklet.addModule("/speech-audio-worklet.js?v=sensevoice-4");
speech.workletReady = true;
} catch (error) {
speech.workletFailed = true;
debug("audio_worklet_fallback", { message: error?.message || String(error) });
}
} else {
speech.workletFailed = true;
}
speech.context = context;
return context;
})();
try {
return await speech.contextPromise;
} finally {
speech.contextPromise = null;
}
}
function audioRms(samples) {
let sum = 0;
for (let index = 0; index < samples.length; index += 1) sum += samples[index] * samples[index];
return Math.sqrt(sum / Math.max(1, samples.length));
}
function handleAudio(chunk, generation) {
if (speech.phase !== "listening" || generation !== speech.generation) return;
speech.chunks.push(chunk);
speech.totalSamples += chunk.length;
const elapsed = performance.now() - speech.startedAt;
const rms = audioRms(chunk);
if (elapsed < 700) speech.noiseFloor = (speech.noiseFloor * 0.82) + (rms * 0.18);
const voiceThreshold = Math.max(0.009, Math.min(0.035, speech.noiseFloor * 2.8));
if (rms >= voiceThreshold) {
speech.consecutiveVoiceSamples += chunk.length;
if (speech.consecutiveVoiceSamples >= speech.sampleRate * 0.09) {
if (speech.speechStartSample === null) {
speech.speechStartSample = Math.max(0, speech.totalSamples - (speech.sampleRate * 0.35));
setPhase("listening", "听到声音了,请继续说…");
debug("speech_started", { sample: speech.speechStartSample, voiceThreshold });
}
speech.lastVoiceSample = speech.totalSamples;
}
} else {
speech.consecutiveVoiceSamples = 0;
}
if (
speech.lastVoiceSample !== null
&& speech.totalSamples - speech.lastVoiceSample >= speech.sampleRate * (ENDPOINT_SILENCE_MS / 1000)
) {
void finishDictation();
} else if (speech.speechStartSample === null && elapsed >= NO_SPEECH_TIMEOUT_MS) {
void cancelDictation("未检测到说话声,请靠近麦克风重试。");
} else if (elapsed >= MAX_RECORDING_MS) {
void finishDictation();
}
}
function connectCapture(context, generation) {
speech.source = context.createMediaStreamSource(speech.stream);
speech.silentGain = context.createGain();
speech.silentGain.gain.value = 0;
if (speech.workletReady && !speech.workletFailed) {
speech.captureNode = new AudioWorkletNode(context, "speech-capture-processor", {
numberOfInputs: 1,
numberOfOutputs: 1,
channelCount: 1,
});
speech.captureNode.port.onmessage = (event) => {
handleAudio(new Float32Array(event.data), generation);
};
debug("capture_connected", { implementation: "AudioWorklet" });
} else {
speech.captureNode = context.createScriptProcessor(2048, 1, 1);
speech.captureNode.onaudioprocess = (event) => {
handleAudio(new Float32Array(event.inputBuffer.getChannelData(0)), generation);
};
debug("capture_connected", { implementation: "ScriptProcessor-fallback" });
}
speech.source.connect(speech.captureNode);
speech.captureNode.connect(speech.silentGain);
speech.silentGain.connect(context.destination);
}
async function startDictation() {
if (speech.phase !== "idle" && !["success", "error"].includes(speech.phase)) return;
if (!speech.backendReady) {
setPhase("error", "语音识别模型未就绪");
return;
}
if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia) {
setPhase("error", "请使用 HTTPS 或 127.0.0.1 开启语音听写");
announce("当前页面不支持安全的麦克风访问");
return;
}
const generation = speech.generation + 1;
speech.generation = generation;
speech.utteranceId = `web-${crypto.randomUUID?.() || `${Date.now()}-${generation}`}`;
try {
setPhase("processing", "正在连接麦克风…");
speech.stream = await preferredMicrophoneStream();
if (generation !== speech.generation) return;
const context = await ensureAudioContext();
speech.sampleRate = context.sampleRate;
speech.chunks = [];
speech.totalSamples = 0;
speech.startedAt = performance.now();
speech.speechStartSample = null;
speech.lastVoiceSample = null;
speech.consecutiveVoiceSamples = 0;
speech.noiseFloor = 0.003;
connectCapture(context, generation);
const trackLabel = speech.stream.getAudioTracks()[0]?.label || "默认麦克风";
setPhase("listening", `正在听写 · ${trackLabel}`);
debug("utterance_started", { sampleRate: speech.sampleRate, trackLabel });
} catch (error) {
await releaseMicrophone();
const message = microphoneErrorMessage(error);
setPhase("error", message);
announce(message);
}
}
async function releaseMicrophone({ closeContext = false } = {}) {
if (speech.captureNode?.port) speech.captureNode.port.onmessage = null;
if (speech.captureNode && "onaudioprocess" in speech.captureNode) {
speech.captureNode.onaudioprocess = null;
}
for (const node of [speech.source, speech.captureNode, speech.silentGain]) {
try { node?.disconnect(); } catch (_error) { /* already disconnected */ }
}
speech.stream?.getTracks().forEach((track) => track.stop());
speech.stream = null;
speech.source = null;
speech.captureNode = null;
speech.silentGain = null;
if (closeContext && speech.context && speech.context.state !== "closed") {
try { await speech.context.close(); } catch (_error) { /* page is closing */ }
speech.context = null;
speech.workletReady = false;
}
}
function concatenateChunks() {
const combined = new Float32Array(speech.totalSamples);
let offset = 0;
for (const chunk of speech.chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
const start = Math.max(0, Math.floor(speech.speechStartSample || 0));
const end = Math.min(
combined.length,
Math.ceil((speech.lastVoiceSample || combined.length) + (speech.sampleRate * 0.45)),
);
return combined.slice(start, end);
}
function downsample(samples, inputRate) {
if (inputRate === TARGET_SAMPLE_RATE) return samples;
const ratio = inputRate / TARGET_SAMPLE_RATE;
const output = new Float32Array(Math.max(1, Math.floor(samples.length / ratio)));
for (let outputIndex = 0; outputIndex < output.length; outputIndex += 1) {
const start = Math.floor(outputIndex * ratio);
const end = Math.max(start + 1, Math.min(samples.length, Math.floor((outputIndex + 1) * ratio)));
let sum = 0;
for (let inputIndex = start; inputIndex < end; inputIndex += 1) sum += samples[inputIndex];
output[outputIndex] = sum / (end - start);
}
return output;
}
function encodePcm16Wav(samples) {
const buffer = new ArrayBuffer(44 + (samples.length * 2));
const view = new DataView(buffer);
const writeAscii = (offset, value) => {
for (let index = 0; index < value.length; index += 1) view.setUint8(offset + index, value.charCodeAt(index));
};
writeAscii(0, "RIFF");
view.setUint32(4, 36 + (samples.length * 2), true);
writeAscii(8, "WAVE");
writeAscii(12, "fmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, TARGET_SAMPLE_RATE, true);
view.setUint32(28, TARGET_SAMPLE_RATE * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
writeAscii(36, "data");
view.setUint32(40, samples.length * 2, true);
for (let index = 0; index < samples.length; index += 1) {
const sample = Math.max(-1, Math.min(1, samples[index]));
view.setInt16(44 + (index * 2), sample < 0 ? sample * 32768 : sample * 32767, true);
}
return buffer;
}
async function transcribe(wav, utteranceId) {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 45000);
try {
const response = await fetch("/api/speech/transcribe", {
method: "POST",
credentials: "same-origin",
cache: "no-store",
headers: {
Accept: "application/json",
"Content-Type": "audio/wav",
"X-Speech-Utterance-Id": utteranceId,
},
body: wav,
signal: controller.signal,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.message || `语音转写失败(HTTP ${response.status})`);
debug("server_result", payload);
return payload;
} finally {
window.clearTimeout(timeout);
}
}
function applyResult(result) {
const { input } = elements();
if (!input) throw new Error("没有找到一句话编排输入框");
const cleanText = String(result.clean_text || result.text || "").trim();
const commands = Array.isArray(result.commands) ? result.commands : [];
if (!cleanText || commands.length === 0) {
window.dispatchEvent(new CustomEvent("cognitive-agent:speech-result", { detail: result }));
return false;
}
speech.applyingResult = true;
input.value = cleanText.slice(0, input.maxLength || 1000);
input.dataset.speechUncertain = String(result.uncertain === true);
input.dataset.speechExecutionEligible = String(result.execution_eligible === true);
input.dataset.speechSemanticStatus = String(result.semantic_status || "");
input.dataset.speechValue = input.value.trim();
input.dataset.speechUtteranceId = String(result.utterance_id || "");
input.dispatchEvent(new Event("input", { bubbles: true }));
speech.applyingResult = false;
input.focus({ preventScroll: true });
window.dispatchEvent(new CustomEvent("cognitive-agent:speech-result", { detail: result }));
return true;
}
async function finishDictation() {
if (speech.finishPromise) return speech.finishPromise;
if (speech.phase !== "listening") return undefined;
speech.finishPromise = (async () => {
setPhase("processing", "说话结束,正在识别并理解指令…");
const hadSpeech = speech.speechStartSample !== null;
const samples = hadSpeech ? concatenateChunks() : null;
const inputRate = speech.sampleRate;
const utteranceId = speech.utteranceId;
await releaseMicrophone();
speech.chunks = [];
if (!hadSpeech || !samples?.length) {
setPhase("error", "未检测到说话声,请重试");
return;
}
try {
const wav = encodePcm16Wav(downsample(samples, inputRate));
const result = await transcribe(wav, utteranceId);
speech.lastResult = result;
const applied = applyResult(result);
const latency = Math.round(result.timings?.total_ms ?? result.inference_ms ?? 0);
if (!applied) {
setPhase("success", `未形成可执行控制指令 · ${latency} ms`);
announce("已保留原始转录,但没有激活控制指令");
} else if (result.semantic_status === "recognized_unsupported") {
setPhase("success", `已识别,但当前车辆能力不支持 · ${latency} ms`);
announce("已完整保留用户语义,但当前车辆能力不支持执行");
} else if (result.semantic_status === "incomplete") {
setPhase("success", `已识别,但控制参数不完整 · ${latency} ms`);
announce("语音中的关键控制参数不完整,当前不会执行");
} else if (result.uncertain === true) {
setPhase("success", `已转写,但存在歧义,请修改确认 · ${latency} ms`);
announce("语音结果需要人工确认,当前不会直接执行");
} else {
setPhase("success", `已理解为 ${result.commands.length} 条指令 · ${latency} ms`);
announce("语音已转换为理解式控制指令");
}
} catch (error) {
const message = error?.name === "AbortError" ? "语音转写超时,请重试。" : error.message;
setPhase("error", message || "语音转写失败,请重试");
announce(message || "语音转写失败");
}
})();
try {
return await speech.finishPromise;
} finally {
speech.finishPromise = null;
}
}
async function cancelDictation(message = "已取消语音听写") {
if (!speech.stream && speech.phase === "idle") return;
speech.phase = "processing";
speech.generation += 1;
await releaseMicrophone();
speech.chunks = [];
setPhase("error", message);
}
async function loadSpeechStatus() {
const { button } = elements();
if (!button) return;
if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia) {
button.disabled = true;
setPhase("error", "语音听写需要 HTTPS 或本机地址");
return;
}
try {
const response = await fetch("/api/speech/status", {
credentials: "same-origin",
cache: "no-store",
headers: { Accept: "application/json" },
});
const status = await response.json();
speech.backendReady = response.ok && status.enabled === true;
debug("backend_status", status);
setPhase("idle");
if (!speech.backendReady) {
button.disabled = true;
button.title = "SenseVoiceSmall 模型未部署";
}
} catch (_error) {
speech.backendReady = false;
button.disabled = true;
button.title = "无法连接语音识别服务";
}
}
function clearSpeechGuardOnManualEdit(event) {
if (speech.applyingResult || !event.isTrusted) return;
const { input } = elements();
if (!input) return;
delete input.dataset.speechUncertain;
delete input.dataset.speechExecutionEligible;
delete input.dataset.speechSemanticStatus;
delete input.dataset.speechValue;
delete input.dataset.speechUtteranceId;
}
function initialize() {
const { button, input } = elements();
if (!button) return;
button.addEventListener("click", () => {
if (speech.phase === "listening") void finishDictation();
else void startDictation();
});
input?.addEventListener("input", clearSpeechGuardOnManualEdit);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && speech.phase === "listening") {
event.preventDefault();
void cancelDictation();
}
});
document.addEventListener("visibilitychange", () => {
if (document.hidden && speech.phase === "listening") void cancelDictation();
});
window.addEventListener("pagehide", () => { void releaseMicrophone({ closeContext: true }); });
void loadSpeechStatus();
}
window.CognitiveAgentSpeech = {
get lastResult() { return speech.lastResult; },
get debugEnabled() { return DEBUG; },
};
document.addEventListener("DOMContentLoaded", initialize);
})();
@@ -18,7 +18,7 @@ spec:
- name: state
type: string
required: true
allowed_values: [on, off]
allowed_values: ["on", "off"]
description: Absolute ventilation state.
- name: level
type: integer
@@ -41,7 +41,7 @@ spec:
- name: state
type: string
required: true
allowed_values: [on, off]
allowed_values: ["on", "off"]
description: Absolute heating state.
- name: level
type: integer
@@ -64,7 +64,7 @@ spec:
- name: action
type: string
required: true
allowed_values: [off, strength, mode]
allowed_values: ["off", strength, mode]
description: Explicit massage operation; a generic on request is not accepted.
- name: strength
type: integer
@@ -19,7 +19,8 @@
"cloud_model",
"cloud_supplement",
"direct_control",
"saved_mode_command_flow"
"saved_mode_command_flow",
"speech_semantic"
]
},
"motion": {
+67
View File
@@ -0,0 +1,67 @@
# Speech pipeline benchmark — 2026-08-28
## Environment
- Windows / Python 3.13
- `sherpa-onnx==1.13.6`, CPU provider, 8 ASR threads
- SenseVoiceSmall full precision, `language=zh`, `use_itn=true`
- Silero VAD: threshold 0.45, min speech 0.16 s, min silence 0.65 s
- 48 text regressions(including reported field failures plus ratio/coordination matrices)
- 16 capability-alignment cases replayed 100 times(1600 samples)
- 43 catalog capability definitions × 5 resolver projections(215 subcases)
- 10 local 16 kHz mono Windows SAPI WAVs covering filler, target refinement,
cross-domain correction, normal multi-command, number/direction correction,
negation, unrelated speech, fast speech and slow speech
The WAV set is synthetic and deliberately kept under ignored `artifacts/tmp`; it is useful for
repeatability but is not a substitute for TAKSTAR V2 recordings in the real cabin.
## Before / after
| Metric | Before | After |
|---|---:|---:|
| Raw exact transcription (10 WAV) | 8/10 | 8/10 |
| Expected understanding text (same 10 cases) | 2/10 | 10/10 |
| Intent exact match | no structured speech Intent | 10/10 |
| Device / action / parameter exact | no structured speech metrics | 10/10 / 10/10 / 10/10 |
| Correction success | unsafe text-only heuristics | 4/4 |
| False command activation | 1 observed wrong-direction activation | 0/2 no-command cases |
| Severe wrong device / wrong number | not reported | 0 / 0 |
| Duplicate transcript rate | not observable (single callback) | 0% |
| ASR inference P50 / P95 | 80 / 110 ms | 79.7 / 110.5 ms |
| Semantic P50 / P95 | 0.03 ms (old regex only) | 0.33 / 0.56 ms(1600 capability samples) |
| Command resolution P50 / P95 | not available | 0.03 / 0.06 ms |
| Steady end-to-end P50 / P95 | not instrumented | 95.3 / 134.1 ms |
| Cold first request | not instrumented end-to-end | 1400.9 ms |
Raw exact stays at 80% because two files differ only in acoustic homophone/punctuation output;
`raw_text` is intentionally not rewritten. The semantic and business metrics operate on separate
fields.
## Full precision vs int8 probe
On sherpa-onnx's official Chinese test WAV, both files produced the same transcript in this one
probe:
| Model | First call including load | Warm P50 | Warm P95 |
|---|---:|---:|---:|
| `model.onnx` | 1514.9 ms | 90.9 ms | 92.7 ms |
| `model.int8.onnx` | 889.6 ms | 77.4 ms | 81.8 ms |
This is insufficient acoustic coverage to prove equal accuracy, so the accuracy-first default
remains full precision. The model file stays configurable through `SPEECH_MODEL_FILE`.
## VAD tuning probe
Thresholds 0.35 / 0.45 / 0.55 and end silence 0.45 / 0.65 / 0.85 s were replayed against the
available Chinese test WAV. All combinations found one segment; threshold 0.45 preserved the
complete utterance boundary. The selected 0.65 s server endpoint, 0.25 s pre-roll and 0.35 s
post-roll are conservative for short commands. Real cabin noise and long-pause recordings remain
required before claiming production calibration.
Reproduce current metrics with:
```bash
.venv/Scripts/python tools/benchmark_speech_pipeline.py \
--audio-dir artifacts/tmp/speech-regression-audio
```
+116
View File
@@ -0,0 +1,116 @@
# 中文语音控制链路
## 整改前的实际链路
```text
getUserMedia
-> ScriptProcessor(4096)
-> 浏览器 RMS 阈值与前后裁剪
-> 单个 16 kHz PCM WAV
-> POST /api/speech/transcribe
-> 单个 sherpa-onnx OfflineStream
-> SenseVoiceSmall 单次 final decode
-> regex 文本清理
-> 直接覆盖输入框
-> 用户提交后进入既有 IntentIR / capability / CAN 链路
```
旧实现没有 AudioWorklet、ASR Web Worker、WASM、streaming partial、VAD segment、
chunk overlap 或 segment assembler。因此观测到的“空调打开打……靠背靠座椅靠背”
不是 partial/final 被前端重复 append,而是 SenseVoice 单次 decode 已返回该 raw 文本;
旧正则层只是尝试掩盖 acoustic false start,且没有结构化 cancelled intent。
## 整改后的链路
```text
Microphone (优先 TAKSTAR V2)
-> AudioContext(跨 utterance 复用)
-> AudioWorklet(旧环境 ScriptProcessor fallback)
-> 自适应 RMS endpoint + 350 ms 前置 / 450 ms 后置采集
-> 16 kHz PCM WAV + utteranceId
-> Silero VAD(0.25 s pre-roll / 0.35 s post-roll,重叠先合并)
-> SenseVoiceSmall zh + ITN(每个 VAD segment 一个 OfflineStream)
-> final ASR callback
-> TranscriptAssembler(replace / commit / order / idempotency / overlap)
-> rawText
-> UnderstandingTranscriptProcessor
-> cleanText + active commands + cancelledFragments + uncertain
-> 页面展示与提交安全门
-> 用户确认后进入既有 IntentIR / capability / CAN 链路
```
模型、tokens、VAD native session 和浏览器 AudioContext 都只初始化一次。服务端请求以
`utteranceId + SHA-256(audio)` 幂等:相同音频返回缓存,不同音频复用同一 ID 返回 409。
## 数据边界
- `raw_text`:SenseVoice 原始证据,只去模型标签和多余空白,永不被语义文本覆盖。
- `clean_text`:用于输入框显示的理解式文本。
- `commands`:与工程 capability catalog 对齐的 active Intent 预览。
- `cancelled_fragments`:否定、自我修正、false start 或显式撤销的证据及 cancelled Intent。
- `uncertain`:参数越界、缺少方向/单位/设备、拼接冲突等不能安全执行的情况。
- `execution_eligible`:存在 active commands、无 uncertain、无校验错误时才为 true。
语音 API 本身不执行任何能力。页面会拒绝直接提交仍带有原始 uncertain 标记的语音结果;
用户编辑后才解除该标记。
## Capability 驱动的语义链路
语音层不再维护可执行设备表。能力事实按以下优先级读取:
1. `domains/*/config/capabilities.yaml` 与
`config/native_can_capabilities/*.yaml`;
2. `CapabilityRegistry` 统一加载 catalog;
3. `NativeCanRuntime.supported_capabilities`、domain runtime、seat compiler 和
mode profile registry 确认可执行性与 executor binding;
4. `CapabilityResolver` 只把中文槽位或既有一句话解析 action 适配到上述事实。
`UnderstandingTranscriptProcessor` 会优先复用
`application/native_can_intent.py` 已有的确定性解析结果,所以驾驶台默认目标、CH1
主驾能力和网页一句话编排保持一致。`vocabulary.py` 只包含 alias、同义词、颜色与
scope 词法映射,不声明车辆支持哪些能力。
每个 command 同时保留 `target_scope`、`property`、`operation`、`value`、`unit`、
`qualifiers`、IntentIR action、executor binding 和 executor payload。`clean_text` 从
这些结构化 command 渲染;unsupported、ambiguous、incomplete 不会产生假的
executor payload,且 UI 会阻止整条原始语音直接提交。
当前代码证据中,前大灯只有反馈和只读 UI,没有控制 capability;因此“打开前大灯”
为 `recognized_unsupported`。零重力是 `config/modes/vehicles/denza_n7/zero_gravity.yaml`
定义的副驾四轴 mode profile,不是主驾单一 capability;明确要求主驾时返回
`recognized_unsupported`。
能力矩阵始终从 catalog 动态生成:
```bash
python tools/report_capability_matrix.py --format markdown
```
## Debug trace
生产默认关闭。设置 `SPEECH_DEBUG=1` 后,每条 JSON 日志包含:
- `utteranceId`;
- `segmentId`、sequence、开始/结束时间、音频 overlap;
- raw ASR final、进入拼接器前文本、拼接后文本;
- clean text、active/cancelled Intent、uncertain reason;
- audio、VAD、每段 ASR、assembly、semantic、recovery、total 耗时。
`SPEECH_DEBUG_RESPONSE=1` 才把 trace 放进 API 响应。浏览器使用 `?speechDebug=1`
查看采集实现、设备、阈值、utteranceId 和服务器结果。
## 默认 VAD 与识别配置
| 配置 | 默认值 |
|---|---:|
| language | `zh` |
| use_itn | `true` |
| Silero threshold | `0.45` |
| min speech | `0.16 s` |
| min silence | `0.65 s` |
| max speech | `12.0 s` |
| pre-roll | `0.25 s` |
| post-roll | `0.35 s` |
参数可通过 `SPEECH_VAD_*` 调整。真实 TAKSTAR V2 车内噪声录音仍需要继续扩展 audio
regression suite;没有真实证据时不自动切换到 int8,也不自动下载 Recovery 模型。
+1
View File
@@ -19,6 +19,7 @@ api = ["fastapi>=0.110", "uvicorn>=0.27"]
can = ["cantools>=39", "python-can>=4.3"]
llm = ["openai>=1.30"]
ir = ["pyserial>=3.5,<4"]
speech = ["sherpa-onnx==1.13.6", "sherpa-onnx-bin==1.13.6"]
test = ["pytest>=8", "pytest-cov>=5"]
dev = ["mypy>=1.9", "ruff>=0.4"]
@@ -2,23 +2,66 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
@dataclass(frozen=True)
class CapabilityDefinition:
"""One capability together with its authoritative catalog provenance."""
name: str
domain: str
availability: str
catalog_id: str
catalog_path: Path
executor_binding: str
spec: dict[str, Any]
@property
def target_devices(self) -> tuple[str, ...]:
return tuple(str(value) for value in self.spec.get("target_devices", ()))
@property
def target_scopes(self) -> tuple[str, ...]:
return tuple(str(value) for value in self.spec.get("target_scopes", ()))
@property
def parameters(self) -> tuple[dict[str, Any], ...]:
return tuple(dict(value) for value in self.spec.get("parameters", ()))
class CapabilityRegistry:
"""Expose domain catalogs without embedding domain semantics in prompts or code."""
def __init__(self, repository_root: Path, executable_domains: set[str]) -> None:
self.root = repository_root
self.executable_domains = set(executable_domains)
self._definitions: list[CapabilityDefinition] = []
self._snapshot = self._load()
def snapshot(self) -> dict[str, Any]:
return self._snapshot
def definitions(self, *, domain: str | None = None) -> tuple[CapabilityDefinition, ...]:
"""Return catalog entries without inventing aliases or execution support."""
return tuple(
definition
for definition in self._definitions
if domain is None or definition.domain == domain
)
def find(self, name: str) -> tuple[CapabilityDefinition, ...]:
"""Return every authoritative definition for a canonical capability id."""
return tuple(
definition for definition in self._definitions if definition.name == name
)
def _load(self) -> dict[str, Any]:
domains = []
catalog_paths = [
@@ -31,16 +74,37 @@ class CapabilityRegistry:
catalog = yaml.safe_load(catalog_path.read_text(encoding="utf-8"))
domain = catalog["spec"]["domain"]
native_can_catalog = catalog_path.parent.name == "native_can_capabilities"
availability = (
"native_can"
if native_can_catalog
else "executable"
if domain in self.executable_domains
else "catalog_only"
)
executor_binding = (
"NativeCanRuntime/NativeCanExecutor"
if native_can_catalog
else "SeatCompiler/SeatExecutor"
if domain == "seat"
else f"ConfiguredDomainRuntime[{domain}]"
)
catalog_id = str(catalog.get("metadata", {}).get("id", catalog_path.stem))
self._definitions.extend(
CapabilityDefinition(
name=str(capability["name"]),
domain=str(domain),
availability=availability,
catalog_id=catalog_id,
catalog_path=catalog_path,
executor_binding=executor_binding,
spec=dict(capability),
)
for capability in catalog["spec"]["capabilities"]
)
domains.append(
{
"domain": domain,
"availability": (
"native_can"
if native_can_catalog
else "executable"
if domain in self.executable_domains
else "catalog_only"
),
"availability": availability,
"capabilities": catalog["spec"]["capabilities"],
}
)
@@ -0,0 +1,128 @@
"""Shared deterministic parsing for Chinese scalar control values.
This module only normalizes values that the user actually spoke. Capability
validation remains responsible for deciding whether a target accepts the
result and whether its type/range is executable.
"""
from __future__ import annotations
import re
_ARABIC_PERCENT = re.compile(r"(-?\d+(?:\.\d+)?)\s*[%%]")
_FRACTION = re.compile(
r"([零〇一二两三四五六七八九十百\d]+)分之"
r"([零〇一二两三四五六七八九十百点\d.]+)"
)
_SLASH_FRACTION = re.compile(r"(?<!\d)(\d+)\s*/\s*(\d+)(?!\d)")
_DIGITS = {
"零": 0,
"〇": 0,
"一": 1,
"二": 2,
"两": 2,
"三": 3,
"四": 4,
"五": 5,
"六": 6,
"七": 7,
"八": 8,
"九": 9,
}
def _chinese_integer(token: str) -> int | None:
if not token:
return 0
if token.isdigit():
return int(token)
if "百" in token:
left, right = token.split("百", 1)
hundreds = _DIGITS.get(left[-1], 1) if left else 1
tail = _chinese_integer(right)
return None if tail is None else hundreds * 100 + tail
if "十" in token:
left, right = token.split("十", 1)
tens = _DIGITS.get(left[-1], 1) if left else 1
ones = _DIGITS.get(right[-1], 0) if right else 0
return tens * 10 + ones
if all(char in _DIGITS for char in token):
return int("".join(str(_DIGITS[char]) for char in token))
return None
def _number(token: str) -> int | float | None:
try:
value = float(token)
except ValueError:
if "点" in token:
whole, fraction = token.split("点", 1)
whole_value = _chinese_integer(whole)
if (
whole_value is None
or not fraction
or any(char not in _DIGITS for char in fraction)
):
return None
value = float(
f"{whole_value}."
+ "".join(str(_DIGITS[char]) for char in fraction)
)
else:
integer = _chinese_integer(token)
if integer is None:
return None
value = float(integer)
return int(value) if value.is_integer() else value
def parse_percentage_value(text: str) -> int | float | None:
"""Return an explicitly spoken percentage/ratio/extreme position.
Bare numbers intentionally do not become percentages. Fractions are
rounded to the nearest whole percentage because every current continuous
vehicle position capability is integer-valued; the resolver still checks
the final range and type.
"""
match = _ARABIC_PERCENT.search(text)
if match:
value = float(match.group(1))
return int(value) if value.is_integer() else value
match = _FRACTION.search(text)
if match:
denominator = _number(match.group(1))
numerator = _number(match.group(2))
if denominator not in {None, 0} and numerator is not None:
return round(float(numerator) * 100 / float(denominator))
match = _SLASH_FRACTION.search(text)
if match and int(match.group(2)) != 0:
return round(int(match.group(1)) * 100 / int(match.group(2)))
if re.search(r"一半|半开|开一半", text):
return 50
extrema = (
(
(
r"最大(?:位置|开度)|最高(?:位置)?|"
r"(?:调|调整|升|抬|开)(?:到|至)(?:最大|最高)|到顶|"
r"全开|完全打开|打开到底"
),
100,
),
(
(
r"最小(?:位置|开度)|最低(?:位置)?|"
r"(?:调|调整|降|收|关)(?:到|至)(?:最小|最低)|"
r"全关|完全关闭|关闭到底"
),
0,
),
)
for pattern, value in extrema:
if re.search(pattern, text):
return value
return None
@@ -9,6 +9,7 @@ from __future__ import annotations
import re
from typing import Any
from cognitive_agent.application.chinese_values import parse_percentage_value
WINDOW_TARGETS = {
"全部": "all",
@@ -626,8 +627,20 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
("加热", "seat.set_driver_heating_native"),
)
present_thermal = [item for item in thermal_features if item[0] in compact]
if len(present_thermal) == 1:
label, capability = present_thermal[0]
coordinated_thermal = re.search(
r"(?:座椅)?(?:通风|加热)(?:和|与|以及|及|、)"
r"(?:打开|开启|启动|关闭|关掉|停止)?"
r"(?:座椅)?(?:通风|加热)",
compact,
)
shared_thermal_state: str | None = None
if coordinated_thermal is not None:
shared_prefix = compact[: coordinated_thermal.start()]
if re.search(r"关闭|关掉|停止|取消|不要|别开", shared_prefix):
shared_thermal_state = "off"
elif re.search(r"打开|开启|启动|启用", shared_prefix):
shared_thermal_state = "on"
for label, capability in present_thermal:
thermal_text = _feature_context(compact, label)
thermal_grade = _grade_alias(thermal_text, include_numeric=False)
parameters: dict[str, Any] | None = None
@@ -639,9 +652,11 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
parameters = {"state": "on", "level": 1}
elif thermal_grade == "low" or re.search(
r"低速|(?:2|二)(?:档|级)", thermal_text
):
) or re.search(r"打开|开启|启动|启用", thermal_text):
parameters = {"state": "on", "level": 2}
elif re.search(r"打开|开启|启动|启用", thermal_text):
elif shared_thermal_state == "off":
parameters = {"state": "off"}
elif shared_thermal_state == "on":
parameters = {"state": "on", "level": 2}
if parameters is not None:
actions.append(_action(
@@ -881,24 +896,11 @@ def _parse_chinese_window_commands(compact: str) -> list[dict[str, Any]]:
window_text = f"{window_text},{clauses[index + 1]}"
targets, explicit_window_target = _window_targets(window_text)
position = re.search(
r"(?:位置|开度|调整到|调到|设为|设置为|开启到|打开到|开到)"
r"(\d{1,3})%",
window_text,
)
half_position = bool(
re.search(
r"(?:开启|打开|开到|开度)?(?:一半|半开)",
window_text,
)
)
position_pct = (
int(position[1]) if position else 50 if half_position else None
)
position_pct = parse_percentage_value(window_text)
if (
position_pct is not None
and all(target not in WINDOW_GROUP_MEMBERS for target in targets)
and position_pct <= 100
and 0 <= position_pct <= 100
):
reason = (
"用户指定了单个车窗的绝对开度。"
+163 -1
View File
@@ -1689,6 +1689,139 @@ class SeatDemoService:
_direct_can_batch=direct_can_batch,
)
def _adapt_speech_understanding(
self,
understanding: dict[str, Any],
context: dict[str, Any],
vehicle_profile: Any,
) -> dict[str, Any]:
"""Revalidate a server-owned speech result into the normal control IR.
Speech is allowed to skip a second natural-language parse, but it never
skips the authoritative capability catalog or ModelPlanAdapter. Native
CAN actions and configured-domain/seat actions are routed through the
same structures used by the rest of one-sentence orchestration.
"""
if understanding.get("execution_eligible") is not True:
raise ValueError("speech understanding is not execution eligible")
commands = understanding.get("commands")
if not isinstance(commands, list) or not commands:
raise ValueError("speech understanding has no executable commands")
native_actions: list[dict[str, Any]] = []
model_requests: list[dict[str, Any]] = []
detected_domains: list[str] = []
def add_payload(payload: object, source_text: str) -> None:
if not isinstance(payload, dict):
raise ValueError("speech command has no validated executor payload")
domain = str(payload.get("domain", ""))
capability = str(payload.get("capability", ""))
target = str(payload.get("target", ""))
operation = str(payload.get("operation", "set"))
parameters = payload.get("parameters")
if not domain or not capability or not target or not isinstance(parameters, dict):
raise ValueError("speech executor payload is incomplete")
definitions = tuple(
definition
for definition in self.capability_registry.find(capability)
if definition.domain == domain
)
if len(definitions) != 1:
raise ValueError(
f"speech capability is not uniquely registered: {domain}.{capability}"
)
if domain not in detected_domains:
detected_domains.append(domain)
# Catalog availability describes where a capability is declared,
# not necessarily which executor owns it. Roof, climate and
# ambient capabilities are declared in domain catalogs but some
# are byte-exact NativeCanRuntime operations. Keep this routing in
# lockstep with CapabilityResolver._executor_binding().
if capability in self.native_can_runtime.supported_capabilities:
native_actions.append(
{
"domain": domain,
"capability": capability,
"target": target,
"operation": operation,
"parameters": deepcopy(parameters),
"parse_source": "speech_semantic",
"source_span": source_text,
"reason": "语音语义层已按真实能力目录解析并由服务端重新校验。",
}
)
return
model_requests.append(
{
"domain": domain,
"capability": capability,
"target_scope": target,
"operation": operation,
"parameters": [
{"name": name, "value": value}
for name, value in parameters.items()
],
"reason": "语音语义层已按真实能力目录解析并由服务端重新校验。",
}
)
for command in commands:
if not isinstance(command, dict):
raise ValueError("speech command must be an object")
if (
command.get("status") != "active"
or command.get("negated") is True
or command.get("resolution_status") != "executable"
or command.get("validation_issues")
):
raise ValueError("speech command failed execution invariants")
source_text = str(command.get("source_text") or understanding.get("raw_text") or "")
payload = command.get("executor_payload")
if command.get("mode_profile_id"):
if not isinstance(payload, dict) or not isinstance(payload.get("operations"), list):
raise ValueError("speech mode command has no validated operations")
for operation_payload in payload["operations"]:
add_payload(operation_payload, source_text)
else:
add_payload(payload, source_text)
plan = {
"detected_domains": detected_domains,
"requests": model_requests,
"ambiguities": [],
"decision_summary": [
"复用服务端语音理解结果;未对 cleanText 进行第二次自然语言解析。"
],
"confidence": min(
float(command.get("confidence", 0.0))
for command in commands
if isinstance(command, dict)
),
}
parsed = self.model_plan_adapter.adapt(
plan,
context=context,
vehicle_profile=vehicle_profile,
utterance=str(understanding.get("raw_text") or ""),
)
parsed["native_can_actions"] = native_actions
parsed.setdefault("semantic_constraints", [])
parsed.setdefault("unresolved_spans", [])
if parsed.get("ambiguities") or parsed.get("deferred_requests"):
raise ValueError("speech command no longer passes capability resolution")
mapped_count = (
len(parsed.get("actions", ()))
+ len(parsed.get("domain_actions", ()))
+ len(parsed.get("native_can_actions", ()))
)
expected_count = len(model_requests) + len(native_actions)
if mapped_count != expected_count:
raise ValueError("speech command mapping lost a validated capability mutation")
return parsed
def process(
self,
utterance: str,
@@ -1710,6 +1843,7 @@ class SeatDemoService:
_on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
_direct_can_batch: tuple[list[str], dict[str, Any]] | None = None,
_direct_mode_profile_id: str | None = None,
_speech_understanding: dict[str, Any] | None = None,
) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport)
# Creating a reusable scene is a configuration workflow. Synthesize
@@ -1736,6 +1870,7 @@ class SeatDemoService:
_on_commands_validated,
_direct_can_batch,
_direct_mode_profile_id,
_speech_understanding,
)
if control_transport == WIRELESS_MQTT_TRANSPORT:
# The head unit owns both physical CAN channels in wireless mode.
@@ -1758,6 +1893,7 @@ class SeatDemoService:
_on_commands_validated,
_direct_can_batch,
_direct_mode_profile_id,
_speech_understanding,
)
if hardware_access_bypass or self._driver_only_vehicle_execution_active(
control_transport
@@ -1780,6 +1916,7 @@ class SeatDemoService:
_on_commands_validated,
_direct_can_batch,
_direct_mode_profile_id,
_speech_understanding,
)
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
with self.hardware_execution.reserve():
@@ -1801,6 +1938,7 @@ class SeatDemoService:
_on_commands_validated,
_direct_can_batch,
_direct_mode_profile_id,
_speech_understanding,
)
def _process_once(
@@ -1821,6 +1959,7 @@ class SeatDemoService:
on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
direct_can_batch: tuple[list[str], dict[str, Any]] | None = None,
direct_mode_profile_id: str | None = None,
speech_understanding: dict[str, Any] | None = None,
) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport)
utterance = utterance.strip()
@@ -1901,7 +2040,7 @@ class SeatDemoService:
[direct_mode_profile]
if direct_mode_profile is not None
else []
if direct_can_batch is not None
if direct_can_batch is not None or speech_understanding is not None
else self.mode_profiles.match_text(
utterance,
vehicle_id=vehicle_profile.id,
@@ -1951,6 +2090,16 @@ class SeatDemoService:
"can_commands": (
list(direct_can_batch[0]) if direct_can_batch is not None else []
),
"speech_understanding": (
{
"utterance_id": speech_understanding.get("utterance_id"),
"raw_text": speech_understanding.get("raw_text"),
"clean_text": speech_understanding.get("clean_text"),
"semantic_status": speech_understanding.get("semantic_status"),
}
if speech_understanding is not None
else None
),
}
started = time.perf_counter()
@@ -2042,6 +2191,19 @@ class SeatDemoService:
parsed = deepcopy(direct_can_batch[1])
provider_used = "direct_can_control"
provider_notice = None
elif speech_understanding is not None:
parsed = self._adapt_speech_understanding(
speech_understanding,
context,
vehicle_profile,
)
parsed = self._mark_control_parse_source(
parsed,
"speech_semantic",
overwrite=True,
)
provider_used = "speech_semantic"
provider_notice = "使用服务端缓存并重新校验的语音结构化意图。"
else:
parsed, provider_used, provider_notice = self._interpret(
utterance,
@@ -0,0 +1,29 @@
"""Local speech-recognition infrastructure."""
from cognitive_agent.infrastructure.speech.assembler import TranscriptAssembler
from cognitive_agent.infrastructure.speech.contracts import (
SpeechCommandIntent,
UnderstandingTranscript,
)
from cognitive_agent.infrastructure.speech.semantic import (
SpeechIntentNormalization,
normalize_speech_intent,
)
from cognitive_agent.infrastructure.speech.sensevoice import (
SpeechRecognitionError,
SpeechTranscriber,
)
from cognitive_agent.infrastructure.speech.understanding import (
UnderstandingTranscriptProcessor,
)
__all__ = [
"SpeechCommandIntent",
"SpeechIntentNormalization",
"SpeechRecognitionError",
"SpeechTranscriber",
"TranscriptAssembler",
"UnderstandingTranscript",
"UnderstandingTranscriptProcessor",
"normalize_speech_intent",
]
@@ -0,0 +1,156 @@
"""Idempotent assembly of partial and final ASR segment callbacks."""
from __future__ import annotations
import re
from dataclasses import replace
from cognitive_agent.infrastructure.speech.contracts import (
AssembledTranscript,
SpeechSegmentResult,
)
_PUNCTUATION = re.compile(r"^[,,。!?!?;;\s]+|[,,。!?!?;;\s]+$")
class TranscriptAssembler:
"""Maintain ordered segment state with replace/commit and overlap semantics."""
def __init__(self, utterance_id: str) -> None:
self.utterance_id = utterance_id
self._segments: dict[str, SpeechSegmentResult] = {}
self._duplicate_callbacks = 0
self._out_of_order_callbacks = 0
self._conflicts: list[str] = []
self._received = 0
self._last_sequence = -1
self._complete = False
def update(self, segment: SpeechSegmentResult) -> AssembledTranscript:
if segment.utterance_id != self.utterance_id:
raise ValueError("segment utterance_id does not match assembler")
if self._complete:
self._duplicate_callbacks += 1
return self.snapshot()
self._received += 1
segment = replace(segment, received_index=self._received)
if segment.sequence < self._last_sequence:
self._out_of_order_callbacks += 1
self._last_sequence = max(self._last_sequence, segment.sequence)
previous = self._segments.get(segment.segment_id)
if previous is not None:
if previous.is_final:
if segment.is_final and self._same_commit(previous, segment):
self._duplicate_callbacks += 1
else:
self._conflicts.append(
f"segment {segment.segment_id} changed after final commit"
)
return self.snapshot()
if self._same_callback(previous, segment):
self._duplicate_callbacks += 1
return self.snapshot()
self._segments[segment.segment_id] = segment
return self.snapshot()
def finish(self) -> AssembledTranscript:
self._complete = True
return self.snapshot()
def reset(self, utterance_id: str) -> None:
self.__init__(utterance_id)
def snapshot(self) -> AssembledTranscript:
ordered = sorted(
self._segments.values(),
key=lambda item: (item.sequence, item.start_ms, item.received_index),
)
finals = tuple(item for item in ordered if item.is_final)
partials = tuple(item for item in ordered if not item.is_final)
text, overlaps = self._assemble_text(finals, partials)
return AssembledTranscript(
utterance_id=self.utterance_id,
text=text,
final_segments=finals,
partial_segments=partials,
duplicate_callbacks=self._duplicate_callbacks,
out_of_order_callbacks=self._out_of_order_callbacks,
conflicts=tuple(dict.fromkeys(self._conflicts)),
overlaps_removed=tuple(overlaps),
complete=self._complete,
)
@staticmethod
def _same_commit(first: SpeechSegmentResult, second: SpeechSegmentResult) -> bool:
return (
first.sequence == second.sequence
and first.start_ms == second.start_ms
and first.end_ms == second.end_ms
and first.text == second.text
)
@classmethod
def _same_callback(cls, first: SpeechSegmentResult, second: SpeechSegmentResult) -> bool:
return first.is_final == second.is_final and cls._same_commit(first, second)
@classmethod
def _assemble_text(
cls,
finals: tuple[SpeechSegmentResult, ...],
partials: tuple[SpeechSegmentResult, ...],
) -> tuple[str, list[str]]:
display = [*finals]
final_ids = {item.segment_id for item in finals}
display.extend(item for item in partials if item.segment_id not in final_ids)
display.sort(key=lambda item: (item.sequence, item.start_ms, item.received_index))
if not display:
return "", []
assembled = display[0].text.strip()
previous = display[0]
overlaps: list[str] = []
for current in display[1:]:
addition = current.text.strip()
overlap = cls._safe_overlap(assembled, addition, previous, current)
if overlap:
overlaps.append(overlap)
addition = addition[len(overlap) :]
assembled = cls._join(assembled, addition)
previous = current
return assembled.strip(), overlaps
@staticmethod
def _safe_overlap(
left: str,
right: str,
previous: SpeechSegmentResult,
current: SpeechSegmentResult,
) -> str:
"""Remove only boundary overlap supported by audio overlap or a long phrase."""
left_body = _PUNCTUATION.sub("", left)
right_body = _PUNCTUATION.sub("", right)
maximum = min(len(left_body), len(right_body), 24)
for length in range(maximum, 1, -1):
candidate = left_body[-length:]
if candidate != right_body[:length]:
continue
audio_evidence = current.audio_overlap_ms > 0 or (
current.start_ms < previous.end_ms
)
if length >= 4 or (audio_evidence and length >= 2):
return candidate
return ""
@staticmethod
def _join(left: str, right: str) -> str:
if not right:
return left
if not left:
return right
if left[-1] in ",,。!?!?;;" or right[0] in ",,。!?!?;;":
return f"{left}{right}"
return f"{left},{right}"
@@ -0,0 +1,396 @@
"""Resolve speech slots against the application's authoritative capability catalogs."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
from cognitive_agent.application.capability_registry import (
CapabilityDefinition,
CapabilityRegistry,
)
from cognitive_agent.application.mode_profile import ModeProfile, ModeProfileRegistry
from cognitive_agent.application.native_can_runtime import NativeCanRuntime
ResolutionStatus = Literal[
"executable",
"unsupported",
"ambiguous",
"incomplete",
]
@dataclass(frozen=True)
class CapabilityResolution:
"""Catalog-backed outcome for one semantic mutation."""
status: ResolutionStatus
definition: CapabilityDefinition | None
issues: tuple[str, ...] = ()
executor_binding: str | None = None
executor_payload: dict[str, Any] | None = None
intent_ir_action: dict[str, Any] | None = None
@dataclass(frozen=True)
class ModeResolution:
"""A repository mode profile and whether it applies to the requested target."""
status: ResolutionStatus
profile: ModeProfile | None
issues: tuple[str, ...] = ()
executor_payloads: tuple[dict[str, Any], ...] = ()
# These are lexical-to-schema selectors, not a second capability registry. A
# selector can only resolve a capability that is present in CapabilityRegistry.
_PROPERTY_SELECTORS: dict[str, tuple[str, ...]] = {
"hvac.power": ("set_power",),
"hvac.temperature": ("temperature",),
"hvac.fan_level": ("fan_level",),
"seat.ventilation": ("ventilation",),
"seat.heating": ("heating",),
"seat.longitudinal_position": ("longitudinal_position",),
"seat.backrest_recline": ("backrest",),
"seat.cushion_tilt": ("cushion_tilt",),
"seat.legrest_height": ("legrest_height",),
"seat.legrest_extension": ("legrest_extension",),
"window.motion": ("set_motion",),
"window.position": ("set_position",),
"roof.shade_motion": ("shade_motion",),
"roof.shade_position": ("shade_position",),
"roof.transmittance": ("transmittance",),
"ambient.power": ("set_power",),
"ambient.color": ("illumination", "color_index"),
"ambient.brightness": ("illumination", "brightness_level"),
"reading_light.power": ("reading_power",),
"indoor_light.power": ("indoor_power",),
"front_light.power": ("front_light", "headlight", "head_lamp"),
}
class CapabilityResolver:
"""Adapt natural-language slots to the capability/executor facts in code."""
def __init__(self, repository_root: Path) -> None:
self.root = Path(repository_root)
self.registry = CapabilityRegistry(
self.root,
{"seat", "climate", "roof", "ambient_light"},
)
self.native_runtime = NativeCanRuntime(self.root)
self.native_supported = self.native_runtime.supported_capabilities
self.mode_profiles = ModeProfileRegistry(self.root / "config" / "modes")
def definitions(self) -> tuple[CapabilityDefinition, ...]:
return self.registry.definitions()
def executor_binding_for(self, definition: CapabilityDefinition) -> str:
"""Return the runtime that actually owns an executable definition."""
return self._executor_binding(definition)
@staticmethod
def semantic_property_for(capability: str, parameters: dict[str, Any] | None = None) -> str:
"""Project an authoritative capability into the speech display vocabulary.
This is deliberately a projection, not an availability table: whether the
capability exists and can execute still comes exclusively from the catalog
and executor runtime.
"""
values = parameters or {}
if capability == "seat.set_driver_axis_native":
return {
"horizontal": "seat.longitudinal_position",
"backrest": "seat.backrest_recline",
"basin": "seat.cushion_tilt",
"height": "seat.height",
}.get(str(values.get("axis")), capability)
projections = (
("set_driver_ventilation", "seat.ventilation"),
("seat.set_ventilation", "seat.ventilation"),
("set_driver_heating", "seat.heating"),
("seat.set_heating", "seat.heating"),
("set_backrest_recline", "seat.backrest_recline"),
("set_longitudinal_position", "seat.longitudinal_position"),
("set_cushion_tilt", "seat.cushion_tilt"),
("set_legrest_height", "seat.legrest_height"),
("set_legrest_extension", "seat.legrest_extension"),
("window.set_motion", "window.motion"),
("window.set_position", "window.position"),
("roof.set_shade_motion", "roof.shade_motion"),
("roof.set_shade_position", "roof.shade_position"),
("roof.set_transmittance", "roof.transmittance"),
("ambient_light.set_power", "ambient.power"),
("ambient_light.set_illumination", "ambient.color"),
("ambient_light.set_color_index", "ambient.color_index"),
("ambient_light.set_brightness_level", "ambient.brightness"),
("climate.set_power", "hvac.power"),
("climate.set_temperature", "hvac.temperature"),
("climate.set_fan_level", "hvac.fan_level"),
("interior_light.set_reading_power", "reading_light.power"),
("interior_light.set_indoor_power", "indoor_light.power"),
)
return next(
(property_name for token, property_name in projections if token in capability),
capability,
)
def resolve_action(self, action: dict[str, Any], *, source_text: str) -> CapabilityResolution:
"""Validate an action produced by the application's existing intent parser."""
capability = str(action.get("capability", ""))
return self.resolve(
domain=str(action.get("domain", capability.partition(".")[0])),
semantic_property=self.semantic_property_for(
capability, dict(action.get("parameters", {}))
),
target_scope=str(action.get("target", action.get("target_scope", "vehicle"))),
target_device=None,
operation=str(action.get("operation", "set")),
parameters=dict(action.get("parameters", {})),
source_text=source_text,
preferred_capability=capability,
)
def match_mode(self, text: str) -> ModeProfile | None:
matches = self.mode_profiles.match_text(text, vehicle_id="denza_n7")
return matches[0] if len(matches) == 1 else None
def resolve_mode(
self,
text: str,
*,
target_scope: str | None,
target_explicit: bool,
) -> ModeResolution:
matches = self.mode_profiles.match_text(text, vehicle_id="denza_n7")
if not matches:
return ModeResolution("unsupported", None, ("mode_profile_not_registered",))
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}
if target_explicit and target_scope not in operation_scopes:
scopes = ",".join(sorted(operation_scopes))
return ModeResolution(
"unsupported",
profile,
(f"mode_profile.{profile.id}仅支持范围{scopes}",),
)
payloads: list[dict[str, Any]] = []
issues: list[str] = []
for operation in profile.operations:
resolution = self.resolve(
domain=operation.domain,
semantic_property="",
target_scope=operation.target_scope,
target_device=None,
operation=operation.operation,
parameters=operation.parameters,
source_text=text,
preferred_capability=operation.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):
return ModeResolution("unsupported", profile, tuple(dict.fromkeys(issues)))
return ModeResolution("executable", profile, executor_payloads=tuple(payloads))
def resolve(
self,
*,
domain: str,
semantic_property: str,
target_scope: str,
target_device: str | None,
operation: str,
parameters: dict[str, Any],
source_text: str,
preferred_capability: str | None = None,
) -> CapabilityResolution:
candidates = list(self.registry.definitions(domain=domain))
if preferred_capability:
candidates = [item for item in candidates if item.name == preferred_capability]
else:
selectors = _PROPERTY_SELECTORS.get(semantic_property, ())
candidates = [
item for item in candidates if any(selector in item.name for selector in selectors)
]
if not candidates:
return CapabilityResolution(
"unsupported",
None,
(f"capability_not_registered:{domain}.{semantic_property}",),
)
parameter_names = set(parameters)
ranked = sorted(
candidates,
key=lambda item: self._score(
item,
target_scope=target_scope,
target_device=target_device,
parameter_names=parameter_names,
),
reverse=True,
)
definition = ranked[0]
top_score = self._score(
definition,
target_scope=target_scope,
target_device=target_device,
parameter_names=parameter_names,
)
equally_ranked = [
item
for item in ranked
if self._score(
item,
target_scope=target_scope,
target_device=target_device,
parameter_names=parameter_names,
)
== top_score
]
if len(equally_ranked) > 1 and not preferred_capability:
return CapabilityResolution(
"ambiguous",
None,
("multiple_capabilities_match:" + ",".join(item.name for item in equally_ranked),),
)
issues = self._validate(definition, target_scope, target_device, parameters)
missing = [issue for issue in issues if issue.startswith("required_parameter_missing:")]
if missing:
status: ResolutionStatus = "incomplete"
elif issues:
status = "unsupported"
elif definition.availability == "catalog_only":
status = "unsupported"
issues.append(f"capability_catalog_only:{definition.name}")
elif (
definition.availability == "native_can" and definition.name not in self.native_supported
):
status = "unsupported"
issues.append(f"native_executor_not_implemented:{definition.name}")
else:
status = "executable"
binding = self._executor_binding(definition) if status == "executable" else None
payload = None
intent_action = None
if status == "executable":
executor_operation = "set" if operation in {"on", "off", "open", "close"} else operation
payload = {
"domain": domain,
"capability": definition.name,
"target": target_scope,
"operation": executor_operation,
"parameters": dict(parameters),
"parse_source": "local_rule",
}
intent_action = {
"domain": domain,
"capability": definition.name,
"target": {
"device": definition.target_devices[0] if definition.target_devices else domain,
"zone": target_scope,
},
"operation": executor_operation,
"parameters": dict(parameters),
"source_span": source_text,
"risk_level": str(definition.spec.get("risk_level", "high")),
"confirmation_policy": str(
definition.spec.get("confirmation_policy", "on_ambiguity")
),
}
return CapabilityResolution(
status,
definition,
tuple(dict.fromkeys(issues)),
binding,
payload,
intent_action,
)
@staticmethod
def _score(
definition: CapabilityDefinition,
*,
target_scope: str,
target_device: str | None,
parameter_names: set[str],
) -> tuple[int, int, int, int, int]:
specs = {str(item["name"]): item for item in definition.parameters}
accepted = set(specs)
missing_required = sum(
1
for name, spec in specs.items()
if spec.get("required") and name not in parameter_names
)
return (
int(target_scope in definition.target_scopes),
int(not parameter_names - accepted),
-missing_required,
int(target_device is not None and target_device in definition.target_devices),
int(definition.availability == "native_can"),
)
@staticmethod
def _validate(
definition: CapabilityDefinition,
target_scope: str,
target_device: str | None,
parameters: dict[str, Any],
) -> list[str]:
issues: list[str] = []
if definition.target_scopes and target_scope not in definition.target_scopes:
issues.append(f"{definition.name}不支持范围{target_scope}")
if (
target_device
and definition.target_devices
and target_device not in definition.target_devices
):
issues.append(f"{definition.name}不支持设备{target_device}")
specs = {str(item["name"]): item for item in definition.parameters}
for name in parameters.keys() - specs.keys():
issues.append(f"unknown_parameter:{definition.name}:{name}")
for name, spec in specs.items():
if spec.get("required") and name not in parameters:
issues.append(f"required_parameter_missing:{definition.name}:{name}")
continue
if name not in parameters:
continue
value = parameters[name]
expected = str(spec.get("type"))
numeric = isinstance(value, (int, float)) and not isinstance(value, bool)
valid_type = {
"number": numeric,
"integer": numeric and float(value).is_integer(),
"string": isinstance(value, str),
"boolean": isinstance(value, bool),
"object": isinstance(value, dict),
"array": isinstance(value, list),
}.get(expected, False)
if not valid_type:
issues.append(f"invalid_parameter_type:{definition.name}:{name}:{expected}")
continue
if numeric and spec.get("minimum") is not None and value < spec["minimum"]:
issues.append(f"{definition.name}.{name}低于工程下限{spec['minimum']}")
if numeric and spec.get("maximum") is not None and value > spec["maximum"]:
issues.append(f"{definition.name}.{name}高于工程上限{spec['maximum']}")
allowed = spec.get("allowed_values")
if allowed is not None and value not in allowed:
issues.append(f"{definition.name}.{name}不在工程允许值中")
return issues
def _executor_binding(self, definition: CapabilityDefinition) -> str:
if definition.name in self.native_supported:
return "NativeCanRuntime/NativeCanExecutor"
return definition.executor_binding
@@ -0,0 +1,233 @@
"""Stable data contracts for the speech-understanding pipeline."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
CommandStatus = Literal["active", "cancelled"]
ResolutionStatus = Literal["executable", "unsupported", "ambiguous", "incomplete"]
TranscriptStatus = Literal[
"recognized_executable",
"recognized_unsupported",
"ambiguous",
"incomplete",
"no_command",
]
@dataclass(frozen=True)
class SpeechCommandIntent:
"""A conservative speech intent aligned with the project's capability catalog."""
domain: str
capability: str | None
target_scope: str
target_device: str
property_name: str
operation: str
parameters: dict[str, Any]
value: Any
unit: str | None
qualifiers: dict[str, Any]
negated: bool
confidence: float
source_text: str
status: CommandStatus = "active"
resolution_status: ResolutionStatus = "executable"
validation_issues: tuple[str, ...] = ()
executor_binding: str | None = None
executor_payload: dict[str, Any] | None = None
intent_ir_action: dict[str, Any] | None = None
mode_profile_id: str | None = None
def to_dict(self) -> dict[str, Any]:
return {
"domain": self.domain,
"capability": self.capability,
"target_scope": self.target_scope,
"target_device": self.target_device,
"property": self.property_name,
"operation": self.operation,
"parameters": dict(self.parameters),
"value": self.value,
"unit": self.unit,
"qualifiers": dict(self.qualifiers),
"negated": self.negated,
"confidence": round(self.confidence, 3),
"source_text": self.source_text,
"status": self.status,
"resolution_status": self.resolution_status,
"validation_issues": list(self.validation_issues),
"executor_binding": self.executor_binding,
"executor_payload": self.executor_payload,
"intent_ir_action": self.intent_ir_action,
"mode_profile_id": self.mode_profile_id,
}
@dataclass(frozen=True)
class CancelledSpeechFragment:
"""Text that was heard but must never become an active control."""
text: str
reason: str
marker: str = ""
commands: tuple[SpeechCommandIntent, ...] = ()
def to_dict(self) -> dict[str, Any]:
return {
"text": self.text,
"reason": self.reason,
"marker": self.marker,
"commands": [command.to_dict() for command in self.commands],
}
@dataclass(frozen=True)
class UnderstandingTranscript:
"""Raw ASR evidence plus display text and non-executable semantic analysis."""
raw_text: str
clean_text: str
commands: tuple[SpeechCommandIntent, ...] = ()
cancelled_fragments: tuple[CancelledSpeechFragment, ...] = ()
semantic_status: TranscriptStatus = "no_command"
uncertain: bool = False
reasons: tuple[str, ...] = ()
removed_fillers: tuple[str, ...] = ()
repair_resolved: bool = False
intent_domains: tuple[str, ...] = ()
changed: bool = False
processing_ms: float = 0.0
command_resolution_ms: float = 0.0
semantic_trace: tuple[dict[str, Any], ...] = ()
@property
def text(self) -> str:
"""Backward-compatible display-text alias."""
return self.clean_text
@property
def execution_eligible(self) -> bool:
return bool(self.commands) and not self.uncertain and all(
command.status == "active"
and not command.negated
and command.resolution_status == "executable"
and not command.validation_issues
for command in self.commands
)
def metadata(self) -> dict[str, object]:
return {
"changed": self.changed,
"removed_fillers": list(self.removed_fillers),
"repair_resolved": self.repair_resolved,
"intent_domains": list(self.intent_domains),
"uncertain": self.uncertain,
"semantic_status": self.semantic_status,
"reason": list(self.reasons),
"execution_eligible": self.execution_eligible,
"processing_ms": round(self.processing_ms, 3),
"command_resolution_ms": round(self.command_resolution_ms, 3),
}
def to_dict(self) -> dict[str, Any]:
return {
"raw_text": self.raw_text,
"clean_text": self.clean_text,
"commands": [command.to_dict() for command in self.commands],
"cancelled_fragments": [
fragment.to_dict() for fragment in self.cancelled_fragments
],
"semantic_status": self.semantic_status,
"uncertain": self.uncertain,
"reason": list(self.reasons),
"execution_eligible": self.execution_eligible,
"normalization": self.metadata(),
"semantic_trace": [dict(item) for item in self.semantic_trace],
}
@dataclass(frozen=True)
class SpeechSegmentResult:
"""One ordered partial or final ASR callback."""
utterance_id: str
segment_id: str
sequence: int
start_ms: int
end_ms: int
text: str
is_final: bool
audio_overlap_ms: int = 0
inference_ms: float = 0.0
received_index: int = 0
def to_dict(self) -> dict[str, Any]:
return {
"utterance_id": self.utterance_id,
"segment_id": self.segment_id,
"sequence": self.sequence,
"start_ms": self.start_ms,
"end_ms": self.end_ms,
"raw_text": self.text,
"state": "final" if self.is_final else "partial",
"audio_overlap_ms": self.audio_overlap_ms,
"inference_ms": round(self.inference_ms, 3),
}
@dataclass(frozen=True)
class AssembledTranscript:
utterance_id: str
text: str
final_segments: tuple[SpeechSegmentResult, ...]
partial_segments: tuple[SpeechSegmentResult, ...]
duplicate_callbacks: int = 0
out_of_order_callbacks: int = 0
conflicts: tuple[str, ...] = ()
overlaps_removed: tuple[str, ...] = ()
complete: bool = False
@property
def uncertain(self) -> bool:
return bool(self.conflicts)
def to_dict(self) -> dict[str, Any]:
return {
"utterance_id": self.utterance_id,
"text": self.text,
"complete": self.complete,
"duplicate_callbacks": self.duplicate_callbacks,
"out_of_order_callbacks": self.out_of_order_callbacks,
"conflicts": list(self.conflicts),
"overlaps_removed": list(self.overlaps_removed),
"segments": [segment.to_dict() for segment in self.final_segments],
}
@dataclass
class SpeechPipelineTimings:
audio_read_ms: float = 0.0
vad_ms: float = 0.0
asr_ms: float = 0.0
assembly_ms: float = 0.0
semantic_ms: float = 0.0
recovery_ms: float = 0.0
total_ms: float = 0.0
extras: dict[str, float] = field(default_factory=dict)
def to_dict(self) -> dict[str, float]:
result = {
"audio_read_ms": round(self.audio_read_ms, 3),
"vad_ms": round(self.vad_ms, 3),
"asr_ms": round(self.asr_ms, 3),
"assembly_ms": round(self.assembly_ms, 3),
"semantic_ms": round(self.semantic_ms, 3),
"recovery_ms": round(self.recovery_ms, 3),
"total_ms": round(self.total_ms, 3),
}
result.update({key: round(value, 3) for key, value in self.extras.items()})
return result
@@ -0,0 +1,46 @@
"""Optional recovery recognizer contract for uncertain fast-path results."""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Protocol
from cognitive_agent.infrastructure.speech.contracts import UnderstandingTranscript
@dataclass(frozen=True)
class RecoveryResult:
raw_text: str
model: str
confidence: float
elapsed_ms: float
class RecoveryRecognizer(Protocol):
@property
def available(self) -> bool: ...
def recognize(
self,
samples: Sequence[float],
sample_rate: int,
fast_path: UnderstandingTranscript,
) -> RecoveryResult | None: ...
class DisabledRecoveryRecognizer:
"""Default recovery implementation; never downloads or guesses a model."""
@property
def available(self) -> bool:
return False
def recognize(
self,
samples: Sequence[float],
sample_rate: int,
fast_path: UnderstandingTranscript,
) -> RecoveryResult | None:
del samples, sample_rate, fast_path
return None
@@ -0,0 +1,61 @@
"""Backward-compatible entry point for structured speech understanding."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from cognitive_agent.infrastructure.speech.understanding import (
UnderstandingTranscriptProcessor,
)
@dataclass(frozen=True)
class SpeechIntentNormalization:
text: str
removed_fillers: tuple[str, ...]
repair_resolved: bool
intent_domains: tuple[str, ...]
changed: bool
commands: tuple[object, ...] = ()
cancelled_fragments: tuple[object, ...] = ()
uncertain: bool = False
reasons: tuple[str, ...] = ()
def metadata(self) -> dict[str, object]:
return {
"changed": self.changed,
"removed_fillers": list(self.removed_fillers),
"repair_resolved": self.repair_resolved,
"intent_domains": list(self.intent_domains),
"uncertain": self.uncertain,
"reason": list(self.reasons),
}
_DEFAULT_PROCESSOR: UnderstandingTranscriptProcessor | None = None
def normalize_speech_intent(
text: str, repository_root: Path | None = None
) -> SpeechIntentNormalization:
global _DEFAULT_PROCESSOR
if repository_root is not None:
processor = UnderstandingTranscriptProcessor(repository_root)
else:
if _DEFAULT_PROCESSOR is None:
repository = Path(__file__).resolve().parents[4]
_DEFAULT_PROCESSOR = UnderstandingTranscriptProcessor(repository)
processor = _DEFAULT_PROCESSOR
result = processor.process(text)
return SpeechIntentNormalization(
text=result.clean_text,
removed_fillers=result.removed_fillers,
repair_resolved=result.repair_resolved,
intent_domains=result.intent_domains,
changed=result.changed,
commands=result.commands,
cancelled_fragments=result.cancelled_fragments,
uncertain=result.uncertain,
reasons=result.reasons,
)
@@ -0,0 +1,493 @@
"""Low-latency local Chinese speech understanding backed by SenseVoiceSmall."""
from __future__ import annotations
import copy
import hashlib
import importlib.metadata
import importlib.util
import math
import os
import re
import sys
import threading
import time
import uuid
import wave
from array import array
from collections import OrderedDict
from collections.abc import Sequence
from dataclasses import replace
from io import BytesIO
from pathlib import Path
from typing import Any
from cognitive_agent.infrastructure.speech.assembler import TranscriptAssembler
from cognitive_agent.infrastructure.speech.contracts import (
SpeechPipelineTimings,
SpeechSegmentResult,
)
from cognitive_agent.infrastructure.speech.recovery import DisabledRecoveryRecognizer
from cognitive_agent.infrastructure.speech.tracing import SpeechTrace, env_flag
from cognitive_agent.infrastructure.speech.understanding import (
UnderstandingTranscriptProcessor,
)
from cognitive_agent.infrastructure.speech.vad import SpeechVadSegmenter
SENSEVOICE_MODEL_NAME = "SenseVoiceSmall"
SENSEVOICE_SAMPLE_RATE = 16_000
SENSEVOICE_MAX_DURATION_SECONDS = 30.0
SENSEVOICE_MIN_DURATION_SECONDS = 0.2
SENSEVOICE_MAX_WAV_BYTES = 2 * 1024 * 1024
_SENSEVOICE_TAG = re.compile(r"<\|[^|>]+\|>")
_UTTERANCE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,95}$")
class SpeechRecognitionError(RuntimeError):
"""A safe, user-facing speech-recognition failure."""
def __init__(self, code: str, message: str, *, status: int = 422) -> None:
super().__init__(message)
self.code = code
self.status = status
class SpeechTranscriber:
"""One recognizer, one VAD, and idempotent per-utterance orchestration."""
def __init__(self, repository_root: Path) -> None:
self.repository_root = repository_root
default_dir = repository_root / "artifacts" / "models" / "sensevoice-small"
self.model_dir = Path(os.getenv("SPEECH_MODEL_DIR", str(default_dir))).expanduser()
configured_model = os.getenv("SPEECH_MODEL_FILE", "model.onnx").strip() or "model.onnx"
self.model_path = self.model_dir / configured_model
self.tokens_path = self.model_dir / "tokens.txt"
self.language = os.getenv("SPEECH_LANGUAGE", "zh").strip() or "zh"
self.use_itn = env_flag("SPEECH_USE_ITN", True)
default_threads = min(8, max(2, (os.cpu_count() or 4) // 2))
self.num_threads = max(
1, min(16, int(os.getenv("SPEECH_NUM_THREADS", str(default_threads))))
)
self.provider = os.getenv("SPEECH_PROVIDER", "cpu").strip().lower() or "cpu"
self.hr_lexicon = Path(os.getenv("SPEECH_HR_LEXICON", "")).expanduser()
self.hr_rule_fsts = Path(os.getenv("SPEECH_HR_RULE_FSTS", "")).expanduser()
self.processor = UnderstandingTranscriptProcessor(repository_root)
self.segmenter = SpeechVadSegmenter(repository_root)
self.recovery = DisabledRecoveryRecognizer()
self._recognizer: Any | None = None
self._load_lock = threading.Lock()
self._decode_lock = threading.Lock()
self._cache_lock = threading.Lock()
self._cache: OrderedDict[str, tuple[str, dict[str, object]]] = OrderedDict()
self._inflight: dict[str, threading.Event] = {}
def status(self) -> dict[str, object]:
runtime_installed = importlib.util.find_spec("sherpa_onnx") is not None
model_installed = self.model_path.is_file() and self.tokens_path.is_file()
try:
runtime_version = importlib.metadata.version("sherpa-onnx")
except importlib.metadata.PackageNotFoundError:
runtime_version = ""
homophone_enabled = self.hr_lexicon.is_file() and self.hr_rule_fsts.is_file()
return {
"enabled": runtime_installed and model_installed,
"runtime_installed": runtime_installed,
"runtime_version": runtime_version,
"model_installed": model_installed,
"loaded": self._recognizer is not None,
"model": SENSEVOICE_MODEL_NAME,
"model_file": self.model_path.name,
"language": self.language,
"use_itn": self.use_itn,
"sample_rate": SENSEVOICE_SAMPLE_RATE,
"max_duration_seconds": SENSEVOICE_MAX_DURATION_SECONDS,
"provider": self.provider,
"num_threads": self.num_threads,
"decoder": "offline-sensevoice-final-only",
"vad": self.segmenter.status(),
"homophone_replacer": {
"enabled": homophone_enabled,
"lexicon": str(self.hr_lexicon) if homophone_enabled else "",
"rule_fsts": str(self.hr_rule_fsts) if homophone_enabled else "",
},
"recovery_recognizer": {"available": self.recovery.available},
"debug_logging": env_flag("SPEECH_DEBUG"),
}
def transcribe_wav(
self, content: bytes, *, utterance_id: str | None = None
) -> dict[str, object]:
resolved_id = self._resolve_utterance_id(utterance_id)
digest = hashlib.sha256(content).hexdigest()
cached = self._claim_or_wait(resolved_id, digest)
if cached is not None:
cached["idempotent_replay"] = True
return cached
try:
result = self._transcribe_wav(content, resolved_id)
with self._cache_lock:
self._cache[resolved_id] = (digest, copy.deepcopy(result))
self._cache.move_to_end(resolved_id)
while len(self._cache) > 64:
self._cache.popitem(last=False)
return result
finally:
with self._cache_lock:
event = self._inflight.pop(resolved_id, None)
if event is not None:
event.set()
def cached_result(self, utterance_id: str) -> dict[str, object]:
"""Return a server-owned recognition result for a later control request.
The browser submits only the opaque utterance id. Executor payloads are
recovered from this bounded server cache so client-side JSON can never be
promoted into a trusted control plan.
"""
resolved_id = self._resolve_utterance_id(utterance_id)
with self._cache_lock:
cached = self._cache.get(resolved_id)
if cached is None:
raise SpeechRecognitionError(
"speech_result_expired",
"语音理解结果已过期,请重新听写后再执行。",
status=409,
)
self._cache.move_to_end(resolved_id)
return copy.deepcopy(cached[1])
def _transcribe_wav(self, content: bytes, utterance_id: str) -> dict[str, object]:
trace = SpeechTrace(utterance_id)
timings = SpeechPipelineTimings()
total_started = time.perf_counter()
read_started = time.perf_counter()
sample_rate, original_samples = self._read_pcm16_wav(content)
samples = self._resample(original_samples, sample_rate, SENSEVOICE_SAMPLE_RATE)
timings.audio_read_ms = (time.perf_counter() - read_started) * 1000
duration_seconds = len(samples) / SENSEVOICE_SAMPLE_RATE
self._validate_duration(duration_seconds)
rms = math.sqrt(math.fsum(sample * sample for sample in samples) / len(samples))
trace.event(
"audio_preprocessed",
sampleRate=SENSEVOICE_SAMPLE_RATE,
sourceSampleRate=sample_rate,
durationMs=round(duration_seconds * 1000),
rms=round(rms, 6),
)
if rms < 0.001:
raise SpeechRecognitionError(
"no_speech", "未检测到清晰的说话声,请靠近麦克风重试。"
)
segmentation = self.segmenter.segment(samples)
timings.vad_ms = segmentation.elapsed_ms
trace.event(
"vad_complete",
usedVad=segmentation.used_vad,
fallbackReason=segmentation.fallback_reason,
segmentCount=len(segmentation.segments),
segments=[
{
"segmentId": item.segment_id,
"startMs": item.start_ms,
"endMs": item.end_ms,
"audioOverlapMs": item.audio_overlap_ms,
}
for item in segmentation.segments
],
stageMs=round(segmentation.elapsed_ms, 3),
)
if segmentation.fallback_reason == "vad_found_no_segment":
raise SpeechRecognitionError(
"no_speech", "VAD 未检测到可靠语音,请靠近麦克风重试。"
)
recognizer = self._get_recognizer()
assembler = TranscriptAssembler(utterance_id)
labels: dict[str, str] = {}
asr_started = time.perf_counter()
with self._decode_lock:
for segment in segmentation.segments:
segment_started = time.perf_counter()
try:
stream = recognizer.create_stream()
stream.accept_waveform(SENSEVOICE_SAMPLE_RATE, segment.samples)
recognizer.decode_stream(stream)
native_result = stream.result
except Exception as exc: # pragma: no cover - native errors vary
raise SpeechRecognitionError(
"inference_failed",
"语音模型暂时无法完成转写,请稍后重试。",
status=503,
) from exc
inference_ms = (time.perf_counter() - segment_started) * 1000
raw_segment = self._clean_text(str(getattr(native_result, "text", "")))
labels = {
"language": self._clean_label(
getattr(native_result, "lang", self.language)
),
"emotion": self._clean_label(getattr(native_result, "emotion", "")),
"event": self._clean_label(getattr(native_result, "event", "")),
}
callback = SpeechSegmentResult(
utterance_id=utterance_id,
segment_id=segment.segment_id,
sequence=segment.sequence,
start_ms=segment.start_ms,
end_ms=segment.end_ms,
text=raw_segment,
is_final=True,
audio_overlap_ms=segment.audio_overlap_ms,
inference_ms=inference_ms,
)
before = assembler.snapshot().text
assembled = assembler.update(callback)
trace.event(
"asr_callback",
segmentId=segment.segment_id,
sequence=segment.sequence,
segmentStartMs=segment.start_ms,
segmentEndMs=segment.end_ms,
state="final",
rawAsrResult=raw_segment,
beforeAssembler=before,
assembledText=assembled.text,
inferenceMs=round(inference_ms, 3),
)
timings.asr_ms = (time.perf_counter() - asr_started) * 1000
assembly_started = time.perf_counter()
assembled = assembler.finish()
timings.assembly_ms = (time.perf_counter() - assembly_started) * 1000
raw_text = assembled.text.strip()
if not raw_text:
raise SpeechRecognitionError(
"no_transcript", "没有识别出有效文字,请重新说一次。"
)
semantic_started = time.perf_counter()
understanding = self.processor.process(raw_text)
timings.semantic_ms = (time.perf_counter() - semantic_started) * 1000
timings.extras["command_resolution_ms"] = understanding.command_resolution_ms
if assembled.uncertain:
understanding = replace(
understanding,
uncertain=True,
reasons=tuple(
dict.fromkeys((*understanding.reasons, "segment_assembly_conflict"))
),
)
trace.event(
"understanding_complete",
rawText=raw_text,
cleanText=understanding.clean_text,
commands=[command.to_dict() for command in understanding.commands],
cancelledFragments=[
fragment.to_dict() for fragment in understanding.cancelled_fragments
],
semanticStatus=understanding.semantic_status,
semanticTrace=list(understanding.semantic_trace),
uncertain=understanding.uncertain,
reason=list(understanding.reasons),
stageMs=round(timings.semantic_ms, 3),
)
if understanding.uncertain and self.recovery.available:
recovery_started = time.perf_counter()
recovered = self.recovery.recognize(
samples, SENSEVOICE_SAMPLE_RATE, understanding
)
timings.recovery_ms = (time.perf_counter() - recovery_started) * 1000
trace.event(
"recovery_complete",
recovered=bool(recovered),
stageMs=round(timings.recovery_ms, 3),
)
timings.total_ms = (time.perf_counter() - total_started) * 1000
payload: dict[str, object] = {
"utterance_id": utterance_id,
"text": understanding.clean_text,
"clean_text": understanding.clean_text,
"raw_text": raw_text,
"commands": [command.to_dict() for command in understanding.commands],
"cancelled_fragments": [
fragment.to_dict() for fragment in understanding.cancelled_fragments
],
"semantic_status": understanding.semantic_status,
"semantic_trace": list(understanding.semantic_trace),
"uncertain": understanding.uncertain,
"reason": list(understanding.reasons),
"execution_eligible": understanding.execution_eligible,
"semantic_cleaned": understanding.changed,
"normalization": understanding.metadata(),
"assembly": assembled.to_dict(),
"segments": [item.to_dict() for item in assembled.final_segments],
**labels,
"duration_ms": round(duration_seconds * 1000),
"inference_ms": round(timings.asr_ms),
"timings": timings.to_dict(),
"model": SENSEVOICE_MODEL_NAME,
"provider": self.provider,
"idempotent_replay": False,
}
debug_events = trace.response_events()
if debug_events:
payload["debug_trace"] = debug_events
return payload
def _claim_or_wait(
self, utterance_id: str, digest: str
) -> dict[str, object] | None:
while True:
with self._cache_lock:
cached = self._cache.get(utterance_id)
if cached is not None:
cached_digest, payload = cached
if cached_digest != digest:
raise SpeechRecognitionError(
"utterance_id_conflict",
"同一 utteranceId 收到了不同录音,已拒绝重复提交。",
status=409,
)
self._cache.move_to_end(utterance_id)
return copy.deepcopy(payload)
event = self._inflight.get(utterance_id)
if event is None:
self._inflight[utterance_id] = threading.Event()
return None
if not event.wait(timeout=50):
raise SpeechRecognitionError(
"duplicate_request_timeout", "重复语音请求等待超时。", status=503
)
def _get_recognizer(self) -> Any:
if self._recognizer is not None:
return self._recognizer
with self._load_lock:
if self._recognizer is not None:
return self._recognizer
if importlib.util.find_spec("sherpa_onnx") is None:
raise SpeechRecognitionError(
"runtime_unavailable",
"语音识别运行时未安装,请安装 speech 可选依赖。",
status=503,
)
if not self.model_path.is_file() or not self.tokens_path.is_file():
raise SpeechRecognitionError(
"model_unavailable", "SenseVoiceSmall 模型尚未部署。", status=503
)
try:
import sherpa_onnx
kwargs: dict[str, object] = {
"model": str(self.model_path),
"tokens": str(self.tokens_path),
"num_threads": self.num_threads,
"sample_rate": SENSEVOICE_SAMPLE_RATE,
"provider": self.provider,
"language": self.language,
"use_itn": self.use_itn,
"debug": env_flag("SPEECH_ASR_DEBUG"),
}
if self.hr_lexicon.is_file() and self.hr_rule_fsts.is_file():
kwargs["hr_lexicon"] = str(self.hr_lexicon)
kwargs["hr_rule_fsts"] = str(self.hr_rule_fsts)
self._recognizer = sherpa_onnx.OfflineRecognizer.from_sense_voice(
**kwargs
)
except Exception as exc: # pragma: no cover - native failures vary
raise SpeechRecognitionError(
"model_load_failed",
"SenseVoiceSmall 模型加载失败,请检查模型文件与运行时。",
status=503,
) from exc
return self._recognizer
@staticmethod
def _resolve_utterance_id(value: str | None) -> str:
if value is None or not value.strip():
return f"server-{uuid.uuid4()}"
candidate = value.strip()
if not _UTTERANCE_ID.fullmatch(candidate):
raise SpeechRecognitionError(
"invalid_utterance_id", "utteranceId 格式无效。", status=400
)
return candidate
@staticmethod
def _validate_duration(duration_seconds: float) -> None:
if duration_seconds < SENSEVOICE_MIN_DURATION_SECONDS:
raise SpeechRecognitionError("audio_too_short", "录音时间太短,请重新说一次。")
if duration_seconds > SENSEVOICE_MAX_DURATION_SECONDS + 0.5:
raise SpeechRecognitionError(
"audio_too_long",
f"单次听写不能超过 {int(SENSEVOICE_MAX_DURATION_SECONDS)} 秒。",
status=413,
)
@staticmethod
def _clean_text(text: str) -> str:
return re.sub(r"\s+", " ", _SENSEVOICE_TAG.sub("", text)).strip()
@staticmethod
def _clean_label(value: object) -> str:
return str(value or "").removeprefix("<|").removesuffix("|>").strip()
@staticmethod
def _resample(
samples: Sequence[float], input_rate: int, output_rate: int
) -> list[float]:
if input_rate == output_rate:
return list(samples)
output_length = max(1, round(len(samples) * output_rate / input_rate))
if output_length == 1:
return [float(samples[0])]
ratio = input_rate / output_rate
result: list[float] = []
for output_index in range(output_length):
position = min(len(samples) - 1, output_index * ratio)
left = int(position)
right = min(len(samples) - 1, left + 1)
fraction = position - left
result.append(
float(samples[left] * (1.0 - fraction) + samples[right] * fraction)
)
return result
@staticmethod
def _read_pcm16_wav(content: bytes) -> tuple[int, list[float]]:
if not content or len(content) > SENSEVOICE_MAX_WAV_BYTES:
raise SpeechRecognitionError(
"invalid_audio_size", "录音数据为空或超过大小限制。", status=413
)
try:
with wave.open(BytesIO(content), "rb") as source:
channels = source.getnchannels()
sample_width = source.getsampwidth()
sample_rate = source.getframerate()
frame_count = source.getnframes()
compression = source.getcomptype()
frames = source.readframes(frame_count)
except (EOFError, wave.Error) as exc:
raise SpeechRecognitionError("invalid_audio", "录音格式无效,请重新录制。") from exc
if compression != "NONE" or sample_width != 2:
raise SpeechRecognitionError("unsupported_audio", "仅支持 16 位 PCM WAV 录音。")
if channels < 1 or channels > 2 or sample_rate < 8_000 or sample_rate > 96_000:
raise SpeechRecognitionError(
"unsupported_audio", "录音声道数或采样率不受支持。"
)
pcm = array("h")
pcm.frombytes(frames)
if sys.byteorder != "little":
pcm.byteswap()
if channels == 2:
mono = [
(pcm[index] + pcm[index + 1]) / (2 * 32768.0)
for index in range(0, len(pcm) - 1, 2)
]
else:
mono = [sample / 32768.0 for sample in pcm]
if not mono:
raise SpeechRecognitionError("invalid_audio", "录音中没有音频采样。")
return sample_rate, mono
@@ -0,0 +1,59 @@
"""Opt-in structured diagnostics for one speech utterance."""
from __future__ import annotations
import json
import logging
import os
import threading
import time
from dataclasses import dataclass, field
from typing import Any
LOGGER = logging.getLogger("cognitive_agent.speech")
_HANDLER_LOCK = threading.Lock()
def env_flag(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
@dataclass
class SpeechTrace:
utterance_id: str
enabled: bool = field(default_factory=lambda: env_flag("SPEECH_DEBUG"))
include_response: bool = field(
default_factory=lambda: env_flag("SPEECH_DEBUG_RESPONSE")
)
started_at: float = field(default_factory=time.perf_counter)
events: list[dict[str, Any]] = field(default_factory=list)
def __post_init__(self) -> None:
if not self.enabled or LOGGER.handlers:
return
with _HANDLER_LOCK:
if LOGGER.handlers:
return
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(message)s"))
LOGGER.addHandler(handler)
LOGGER.setLevel(logging.INFO)
LOGGER.propagate = False
def event(self, stage: str, **details: Any) -> None:
if not self.enabled:
return
payload = {
"utteranceId": self.utterance_id,
"stage": stage,
"elapsedMs": round((time.perf_counter() - self.started_at) * 1000, 3),
**details,
}
self.events.append(payload)
LOGGER.info("speech_trace %s", json.dumps(payload, ensure_ascii=False))
def response_events(self) -> list[dict[str, Any]]:
return list(self.events) if self.enabled and self.include_response else []
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,173 @@
"""Sherpa-ONNX Silero VAD with conservative boundary padding."""
from __future__ import annotations
import importlib.util
import os
import threading
import time
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from cognitive_agent.infrastructure.speech.tracing import env_flag
@dataclass(frozen=True)
class VadAudioSegment:
segment_id: str
sequence: int
start_sample: int
end_sample: int
samples: tuple[float, ...]
audio_overlap_ms: int = 0
@property
def start_ms(self) -> int:
return round(self.start_sample / 16)
@property
def end_ms(self) -> int:
return round(self.end_sample / 16)
@dataclass(frozen=True)
class VadSegmentation:
segments: tuple[VadAudioSegment, ...]
elapsed_ms: float
used_vad: bool
fallback_reason: str = ""
class SpeechVadSegmenter:
"""Reuse one native VAD instance and reset its state per utterance."""
sample_rate = 16_000
def __init__(self, repository_root: Path) -> None:
default_model = repository_root / "artifacts" / "models" / "silero-vad" / "silero_vad.onnx"
self.model_path = Path(os.getenv("SPEECH_VAD_MODEL", str(default_model))).expanduser()
self.enabled = env_flag("SPEECH_VAD_ENABLED", True)
self.threshold = float(os.getenv("SPEECH_VAD_THRESHOLD", "0.45"))
self.min_speech_seconds = float(os.getenv("SPEECH_VAD_MIN_SPEECH", "0.16"))
self.min_silence_seconds = float(os.getenv("SPEECH_VAD_MIN_SILENCE", "0.65"))
self.max_speech_seconds = float(os.getenv("SPEECH_VAD_MAX_SPEECH", "12.0"))
self.pre_roll_seconds = float(os.getenv("SPEECH_VAD_PRE_ROLL", "0.25"))
self.post_roll_seconds = float(os.getenv("SPEECH_VAD_POST_ROLL", "0.35"))
self.buffer_seconds = float(os.getenv("SPEECH_VAD_BUFFER", "32"))
self.num_threads = max(1, min(4, int(os.getenv("SPEECH_VAD_THREADS", "1"))))
self._vad: object | None = None
self._lock = threading.Lock()
@property
def available(self) -> bool:
return (
self.enabled
and self.model_path.is_file()
and importlib.util.find_spec("sherpa_onnx") is not None
)
def status(self) -> dict[str, object]:
return {
"enabled": self.enabled,
"available": self.available,
"loaded": self._vad is not None,
"model_path": str(self.model_path),
"threshold": self.threshold,
"min_speech_seconds": self.min_speech_seconds,
"min_silence_seconds": self.min_silence_seconds,
"max_speech_seconds": self.max_speech_seconds,
"pre_roll_seconds": self.pre_roll_seconds,
"post_roll_seconds": self.post_roll_seconds,
}
def segment(self, samples: Sequence[float]) -> VadSegmentation:
started = time.perf_counter()
if not self.available:
return self._fallback(samples, started, "vad_unavailable")
with self._lock:
vad = self._get_vad()
try:
vad.reset()
chunk_size = 512
for offset in range(0, len(samples), chunk_size):
vad.accept_waveform(samples[offset : offset + chunk_size])
vad.flush()
raw_boundaries: list[tuple[int, int]] = []
while not vad.empty():
segment = vad.front
start = max(0, int(segment.start))
end = min(len(samples), start + len(segment.samples))
if end > start:
raw_boundaries.append((start, end))
vad.pop()
finally:
vad.reset()
if not raw_boundaries:
return self._fallback(samples, started, "vad_found_no_segment")
pre_roll = round(self.pre_roll_seconds * self.sample_rate)
post_roll = round(self.post_roll_seconds * self.sample_rate)
padded = [
(max(0, start - pre_roll), min(len(samples), end + post_roll))
for start, end in raw_boundaries
]
# Padding must not create overlapped audio submitted to two ASR streams.
merged: list[tuple[int, int]] = []
for start, end in padded:
if merged and start <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
else:
merged.append((start, end))
segments = tuple(
VadAudioSegment(
segment_id=f"segment-{sequence}",
sequence=sequence,
start_sample=start,
end_sample=end,
samples=tuple(samples[start:end]),
)
for sequence, (start, end) in enumerate(merged)
)
return VadSegmentation(
segments,
(time.perf_counter() - started) * 1000,
used_vad=True,
)
def _get_vad(self):
if self._vad is not None:
return self._vad
import sherpa_onnx
silero = sherpa_onnx.SileroVadModelConfig(
model=str(self.model_path),
threshold=self.threshold,
min_silence_duration=self.min_silence_seconds,
min_speech_duration=self.min_speech_seconds,
window_size=512,
max_speech_duration=self.max_speech_seconds,
)
config = sherpa_onnx.VadModelConfig(
silero_vad=silero,
sample_rate=self.sample_rate,
num_threads=self.num_threads,
provider="cpu",
debug=env_flag("SPEECH_VAD_DEBUG"),
)
self._vad = sherpa_onnx.VoiceActivityDetector(config, self.buffer_seconds)
return self._vad
@staticmethod
def _fallback(
samples: Sequence[float], started: float, reason: str
) -> VadSegmentation:
segment = VadAudioSegment(
"segment-0", 0, 0, len(samples), tuple(samples)
)
return VadSegmentation(
(segment,),
(time.perf_counter() - started) * 1000,
used_vad=False,
fallback_reason=reason,
)
@@ -0,0 +1,283 @@
"""Chinese lexical aliases used before authoritative capability resolution."""
from __future__ import annotations
import re
from collections.abc import Iterable
from dataclasses import dataclass
@dataclass(frozen=True)
class DeviceTerm:
canonical: str
domain: str
semantic_key: str
target_device: str
aliases: tuple[str, ...]
DEVICE_TERMS: tuple[DeviceTerm, ...] = (
DeviceTerm("空调", "climate", "hvac.power", "hvac", ("空调", "空调系统")),
DeviceTerm(
"空调温度",
"climate",
"hvac.temperature",
"hvac",
("空调温度", "温度"),
),
DeviceTerm(
"空调风量",
"climate",
"hvac.fan_level",
"hvac",
("空调风量", "风量", "风速"),
),
DeviceTerm(
"座椅靠背",
"seat",
"seat.backrest_recline",
"seat_backrest_motor",
("座椅靠背", "座椅的靠背", "靠背", "椅背"),
),
DeviceTerm(
"座椅",
"seat",
"seat.longitudinal_position",
"seat_longitudinal_motor",
("座椅", "坐椅", "座位"),
),
DeviceTerm(
"座椅坐垫",
"seat",
"seat.cushion_tilt",
"seat_cushion_tilt_motor",
("座椅坐垫", "坐垫", "坐盆"),
),
DeviceTerm(
"座椅腿托",
"seat",
"seat.legrest_height",
"seat_legrest_lift_motor",
("座椅腿托", "腿托", "脚托", "小腿托"),
),
DeviceTerm(
"座椅通风",
"seat",
"seat.ventilation",
"seat",
("座椅通风", "通风座椅"),
),
DeviceTerm(
"座椅加热",
"seat",
"seat.heating",
"seat",
("座椅加热", "加热座椅"),
),
DeviceTerm(
"车窗",
"window",
"window.motion",
"window",
("车窗", "窗户", "玻璃窗"),
),
DeviceTerm(
"遮阳帘",
"roof",
"roof.shade_motion",
"roof_shade",
("遮阳帘", "天幕帘", "天窗遮阳帘"),
),
DeviceTerm(
"天幕",
"roof",
"roof.transmittance",
"roof_glass",
("天幕", "天窗玻璃"),
),
DeviceTerm(
"氛围灯",
"ambient_light",
"ambient.power",
"ambient_light",
("氛围灯", "环境灯"),
),
DeviceTerm(
"阅读灯",
"interior_light",
"reading_light.power",
"reading_light",
("阅读灯",),
),
DeviceTerm(
"车内灯",
"interior_light",
"indoor_light.power",
"indoor_light",
("车内灯", "室内灯", "顶灯"),
),
DeviceTerm(
"前大灯",
"front_light",
"front_light.power",
"front_light",
("前大灯", "大灯", "前照灯"),
),
)
SCOPE_ALIASES: dict[str, tuple[str, ...]] = {
"driver": ("主驾", "主驾驶", "驾驶位", "司机位", "左前"),
"passenger": ("副驾", "副驾驶", "乘客位", "右前"),
"front_row": ("前排",),
"rear_row": ("后排",),
"rear_left": ("左后", "后排左侧"),
"rear_right": ("右后", "后排右侧"),
"vehicle": ("全车", "整车", "车内"),
"all": ("全部", "所有", "四个"),
}
ACTION_ALIASES: dict[str, tuple[str, ...]] = {
"on": ("打开", "开启", "启动", "开"),
"off": ("关闭", "关掉", "关上", "停止", "关"),
"set": (
"调整到",
"调整为",
"调节到",
"调节为",
"设置到",
"设置为",
"调到",
"调成",
"变成",
"设为",
"开到",
),
"forward": ("向前", "往前", "朝前", "前移", "前倾"),
"backward": ("向后", "往后", "朝后", "后移", "后仰"),
"up": ("向上", "往上", "升高", "抬高", "抬起"),
"down": ("向下", "往下", "降低", "降下"),
"adjust": ("调整", "调节", "移动", "调", "移"),
}
CORRECTION_MARKERS: tuple[str, ...] = (
"重新说",
"说错了",
"准确地说",
"应该是",
"我是说",
"更正为",
"更正成",
"不对",
"口误",
"改成",
"改为",
"重来",
)
CANCELLATION_MARKERS: tuple[str, ...] = ("算了", "取消", "作废")
POSITIVE_CONNECTORS: tuple[str, ...] = (
"然后",
"同时",
"并且",
"接着",
"随后",
"再",
"并",
)
NEGATION_PREFIXES: tuple[str, ...] = ("不要", "别", "不想", "无需", "不必", "禁止")
UNITS: tuple[str, ...] = ("摄氏度", "百分比", "毫米", "厘米", "毫秒", "分钟", "度", "%", "档", "秒")
COLOR_ALIASES: dict[str, tuple[str, ...]] = {
"#FF0000": ("红色", "红灯", "正红"),
"#00FF00": ("绿色", "绿灯"),
"#0000FF": ("蓝色", "蓝灯"),
"#FFFFFF": ("白色", "白灯"),
"#FFFF00": ("黄色", "黄灯"),
"#FFA500": ("橙色", "橘色"),
"#800080": ("紫色", "紫灯"),
}
def _alias_pattern(values: Iterable[str]) -> re.Pattern[str]:
ordered = sorted(set(values), key=len, reverse=True)
return re.compile("|".join(re.escape(value) for value in ordered))
DEVICE_ALIAS_PATTERN = _alias_pattern(
alias for term in DEVICE_TERMS for alias in term.aliases
)
ACTION_ALIAS_PATTERN = _alias_pattern(
alias for aliases in ACTION_ALIASES.values() for alias in aliases
)
SCOPE_ALIAS_PATTERN = _alias_pattern(
alias for aliases in SCOPE_ALIASES.values() for alias in aliases
)
def device_terms_in(text: str) -> list[tuple[int, int, DeviceTerm, str]]:
"""Return longest non-overlapping device terms in source order."""
alias_map = {
alias: term
for term in DEVICE_TERMS
for alias in term.aliases
}
return [
(match.start(), match.end(), alias_map[match.group(0)], match.group(0))
for match in DEVICE_ALIAS_PATTERN.finditer(text)
]
def action_terms_in(text: str) -> list[tuple[int, int, str, str]]:
alias_map = {
alias: action
for action, aliases in ACTION_ALIASES.items()
for alias in aliases
}
device_spans = [(start, end) for start, end, _, _ in device_terms_in(text)]
matches: list[tuple[int, int, str, str]] = []
for match in ACTION_ALIAS_PATTERN.finditer(text):
# Single-character actions such as ``调`` are useful in ``往后调``, but
# must not turn the ``调`` inside ``空调`` into a later action callback.
starts_inside_device = any(
start <= match.start() < end for start, end in device_spans
)
if starts_inside_device:
continue
matches.append(
(match.start(), match.end(), alias_map[match.group(0)], match.group(0))
)
return matches
def scope_in(text: str, *, default: str, domain: str | None = None) -> str:
matches = [
(match.start(), scope)
for scope, aliases in SCOPE_ALIASES.items()
for alias in aliases
for match in re.finditer(re.escape(alias), text)
]
scope = min(matches)[1] if matches else default
if domain == "window":
return {
"driver": "front_left",
"passenger": "front_right",
"front_row": "front",
"rear_row": "rear",
}.get(scope, scope)
return scope
def color_in(text: str) -> tuple[str, str] | None:
"""Return a canonical RGB value and the exact spoken color alias."""
matches = [
(match.start(), color, alias)
for color, aliases in COLOR_ALIASES.items()
for alias in aliases
for match in re.finditer(re.escape(alias), text)
]
if not matches:
return None
_, color, alias = min(matches)
return color, alias
+88 -10
View File
@@ -13,28 +13,28 @@ import socket
import threading
import time
from copy import deepcopy
from http.cookies import CookieError, SimpleCookie
from http import HTTPStatus
from http.cookies import CookieError, SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import ParseResult, parse_qs, quote, unquote, urlparse
from cognitive_agent.config_loader import load_local_env
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
# Repository-local launch configuration is deliberate and must not be shadowed
# by stale variables inherited from an IDE or an earlier model experiment.
load_local_env(REPOSITORY_ROOT / ".env", override=True)
load_local_env(REPOSITORY_ROOT / ".env.auth", override=True)
from cognitive_agent.auth import AuthConfig, AuthRuntime, verify_password # noqa: E402
from cognitive_agent.application.seat_service import SeatDemoService # noqa: E402
from cognitive_agent.application.gesture_bridge import GestureCapabilityBridge # noqa: E402
from cognitive_agent.application.calibration.service import ( # noqa: E402
ApiError as CalibrationApiError,
)
from cognitive_agent.application.calibration.service import (
CalibrationService,
)
from cognitive_agent.application.gesture_bridge import GestureCapabilityBridge # noqa: E402
from cognitive_agent.application.seat_service import SeatDemoService # noqa: E402
from cognitive_agent.application.self_extension import ( # noqa: E402
ExtensionIntegrationError,
GatewayError,
@@ -44,17 +44,21 @@ from cognitive_agent.application.self_extension import ( # noqa: E402
resolve_active_peripheral_command,
)
from cognitive_agent.application.self_repair import SelfRepairService # noqa: E402
from cognitive_agent.auth import AuthConfig, AuthRuntime, verify_password # noqa: E402
from cognitive_agent.infrastructure.can.access import ( # noqa: E402
HardwareAccessRequiredError,
)
from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
HardwareBusyError,
HardwareSafetyError,
)
from cognitive_agent.infrastructure.can.gateway import CanGatewayError # noqa: E402
from cognitive_agent.infrastructure.can.access import ( # noqa: E402
HardwareAccessRequiredError,
)
from cognitive_agent.infrastructure.llm.openai_responses import OpenAIIntentError # noqa: E402
from cognitive_agent.infrastructure.nfc import NfcReaderService # noqa: E402
from cognitive_agent.infrastructure.speech import ( # noqa: E402
SpeechRecognitionError,
SpeechTranscriber,
)
STATIC_ROOT = REPOSITORY_ROOT / "apps" / "web-console"
mimetypes.add_type("application/manifest+json", ".webmanifest")
@@ -179,6 +183,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
repository_root=REPOSITORY_ROOT,
)
repair = SelfRepairService()
speech = SpeechTranscriber(REPOSITORY_ROOT)
auth = AuthRuntime(AuthConfig.from_env())
vehicle_display_sync = VehicleDisplaySync()
server_version = "CognitiveAgentCabinDemo/2.0"
@@ -374,6 +379,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
return
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
return
if path == "/api/speech/status":
self._json(self.speech.status())
return
if path.startswith("/api/requests/"):
self._serve_request(path.removeprefix("/api/requests/"))
return
@@ -389,6 +397,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
return
if not self._authorize(path):
return
if path == "/api/speech/transcribe":
self._handle_speech_transcription()
return
if path in {
"/api/hardware/access/request",
"/api/hardware/access/release",
@@ -518,6 +529,32 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
payload.get("control_transport", "wired_can")
),
}
speech_utterance_id = str(
payload.get("speech_utterance_id", "")
).strip()
if speech_utterance_id:
if path != "/api/requests":
raise ValueError(
"speech semantic execution is only accepted by /api/requests"
)
speech_understanding = self.speech.cached_result(
speech_utterance_id
)
if str(speech_understanding.get("clean_text", "")).strip() != utterance:
raise SpeechRecognitionError(
"speech_text_changed",
"语音文本已被修改,请确认修改后的内容再执行。",
status=409,
)
if speech_understanding.get("execution_eligible") is not True:
raise SpeechRecognitionError(
"speech_not_executable",
"语音意图尚未通过能力或参数校验,已拒绝执行。",
status=422,
)
provider = "local"
common_arguments["provider"] = provider
common_arguments["_speech_understanding"] = speech_understanding
if (
path != "/api/can/commands"
and "application/x-ndjson" in self.headers.get("Accept", "")
@@ -646,6 +683,12 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
{"error": "extension_request_ambiguous", "message": str(exc)},
HTTPStatus.UNPROCESSABLE_ENTITY,
)
except SpeechRecognitionError as exc:
fail_display_sync(exc.code, exc)
self._json(
{"error": exc.code, "message": str(exc)},
HTTPStatus(exc.status),
)
except (ValueError, json.JSONDecodeError) as exc:
fail_display_sync("invalid_request", exc)
self._json(
@@ -2135,6 +2178,41 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
raise ValueError("request body must be a JSON object")
return payload
def _handle_speech_transcription(self) -> None:
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
if content_type not in {"audio/wav", "audio/wave", "audio/x-wav"}:
self._json(
{
"error": "unsupported_media_type",
"message": "语音听写接口仅接受 16 位 PCM WAV 音频。",
},
HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
)
return
try:
content_length = int(self.headers.get("Content-Length", "0"))
if content_length <= 0 or content_length > 2 * 1024 * 1024:
raise SpeechRecognitionError(
"invalid_audio_size",
"录音数据为空或超过大小限制。",
status=413,
)
result = self.speech.transcribe_wav(
self.rfile.read(content_length),
utterance_id=self.headers.get("X-Speech-Utterance-Id"),
)
self._json(result)
except SpeechRecognitionError as exc:
self._json(
{"error": exc.code, "message": str(exc)},
HTTPStatus(exc.status),
)
except (TypeError, ValueError):
self._json(
{"error": "invalid_audio", "message": "录音请求格式无效。"},
HTTPStatus.BAD_REQUEST,
)
def _authorize(self, path: str) -> bool:
if not self.auth.config.enabled or path in PUBLIC_AUTH_PATHS:
return True
@@ -2407,7 +2485,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
self.send_header("Referrer-Policy", "no-referrer")
self.send_header(
"Permissions-Policy",
"camera=(self), microphone=(), geolocation=(), payment=(), usb=()",
"camera=(self), microphone=(self), geolocation=(), payment=(), usb=()",
)
self.send_header(
"Content-Security-Policy",
+199 -4
View File
@@ -13,16 +13,19 @@ from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from cognitive_agent.auth import AuthConfig, AuthRuntime, hash_password
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
from cognitive_agent.application.mode_profile import ModeProfileRegistry
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
from cognitive_agent.application.self_extension import (
SelfExtensionService,
SimulationGateway,
)
from cognitive_agent.infrastructure.llm.openai_responses import OpenAIIntentError
from cognitive_agent.auth import AuthConfig, AuthRuntime, hash_password
from cognitive_agent.infrastructure.can.access import HardwareAccessController
from cognitive_agent.infrastructure.can.gateway import CanGatewayError
from cognitive_agent.infrastructure.llm.openai_responses import OpenAIIntentError
from cognitive_agent.infrastructure.speech.understanding import (
UnderstandingTranscriptProcessor,
)
from cognitive_agent.server import SeatDemoHandler
@@ -599,6 +602,18 @@ operations:
f"{self.base_url}/console-v2.css?v=delivery-state-43", timeout=3
) 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
) as response:
speech_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/speech-input.css?v=sensevoice-4", timeout=3
) as response:
speech_styles = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/speech-audio-worklet.js?v=sensevoice-4", timeout=3
) as response:
speech_worklet = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/self-extension.js?v=gscu-tcp-40", timeout=3
) as response:
@@ -621,6 +636,8 @@ operations:
nfc_status = json.loads(response.read().decode("utf-8"))
with self.opener.open(f"{self.base_url}/api/runtime", timeout=3) as response:
runtime = json.loads(response.read().decode("utf-8"))
with self.opener.open(f"{self.base_url}/api/speech/status", timeout=3) as response:
speech_status = json.loads(response.read().decode("utf-8"))
self.assertIn("511工程-智能座舱", html)
self.assertIn('id="seatTwinCanvas"', html)
@@ -641,6 +658,10 @@ operations:
self.assertNotIn('class="vehicle-system-hud"', html)
self.assertIn('src="/console-v2.js?v=delivery-state-50"', html)
self.assertIn('href="/console-v2.css?v=delivery-state-43"', html)
self.assertIn('src="/speech-input.js?v=sensevoice-6"', html)
self.assertIn('href="/speech-input.css?v=sensevoice-4"', html)
self.assertIn('id="speechDictationButton"', html)
self.assertIn('class="speech-waveform"', html)
self.assertIn('<body class="console-v2">', html)
self.assertIn('id="v2BootScreen"', html)
self.assertIn('正在加载车机控制与实时状态', html)
@@ -891,8 +912,30 @@ 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-v220"', service_worker)
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v221"', service_worker)
self.assertIn('"/app.js?v=dual-seat-109"', service_worker)
self.assertIn('"/speech-input.js?v=sensevoice-6"', service_worker)
self.assertIn('"/speech-input.css?v=sensevoice-4"', service_worker)
self.assertIn('"/speech-audio-worklet.js?v=sensevoice-4"', service_worker)
self.assertIn('fetch("/api/speech/transcribe"', speech_script)
self.assertIn('"X-Speech-Utterance-Id"', speech_script)
self.assertIn("result.commands", speech_script)
self.assertIn("AudioWorkletNode", speech_script)
self.assertIn('registerProcessor("speech-capture-processor"', speech_worklet)
self.assertIn("navigator.mediaDevices.getUserMedia", speech_script)
self.assertIn("PREFERRED_MICROPHONE", speech_script)
self.assertIn("ENDPOINT_SILENCE_MS", speech_script)
self.assertIn("speechExecutionEligible", speech_script)
self.assertIn("speechSemanticStatus", speech_script)
self.assertIn('"recognized_unsupported"', speech_script)
self.assertIn('"incomplete"', speech_script)
self.assertIn("speechExecutionEligible", app_script)
self.assertIn("speech_utterance_id", app_script)
self.assertIn("speechUtteranceId", speech_script)
self.assertIn("语音意图已识别,但当前车辆能力不支持执行", app_script)
self.assertIn("语音控制参数不完整,请补充后再执行", app_script)
self.assertIn(".speech-dictation.is-listening", speech_styles)
self.assertEqual(speech_status["model"], "SenseVoiceSmall")
self.assertIn('"/assets/pets/self-extension-cat.png?v=3"', service_worker)
self.assertIn(".then(() => self.skipWaiting())", service_worker)
self.assertNotIn("const cached = await caches.match(request)", service_worker)
@@ -919,6 +962,7 @@ operations:
self.assertIn("BENCH BUSY", app_script)
self.assertIn("FBXLoader.js", html)
self.assertIn('data-seat-layout="five-seat"', html)
self.assertIn('data-active-seat="front-right"', html)
self.assertNotIn('placeholder="请输入服务端配置令牌"', html)
self.assertIn('data-callout="backrest_recline"', html)
@@ -1126,6 +1170,157 @@ operations:
)
)
def test_speech_transcription_endpoint_forwards_wav_without_persisting_it(self) -> None:
class SpeechStub:
received = b""
@classmethod
def transcribe_wav(
cls, content: bytes, *, utterance_id: str | None = None
) -> dict[str, object]:
cls.received = content
return {
"utterance_id": utterance_id,
"text": "打开空调到二十四度。",
"language": "zh",
"duration_ms": 800,
"inference_ms": 92,
"model": "SenseVoiceSmall",
}
original_speech = SeatDemoHandler.speech
SeatDemoHandler.speech = SpeechStub()
try:
wav_content = b"RIFF-speech-test"
request = urllib.request.Request(
f"{self.base_url}/api/speech/transcribe",
method="POST",
headers={
"Content-Type": "audio/wav",
"Accept": "application/json",
"X-Speech-Utterance-Id": "e2e-speech-1",
},
data=wav_content,
)
with self.opener.open(request, timeout=3) as response:
payload = json.loads(response.read().decode("utf-8"))
permissions_policy = response.headers["Permissions-Policy"]
finally:
SeatDemoHandler.speech = original_speech
self.assertEqual(SpeechStub.received, wav_content)
self.assertEqual(payload["text"], "打开空调到二十四度。")
self.assertEqual(payload["utterance_id"], "e2e-speech-1")
self.assertIn("microphone=(self)", permissions_policy)
def test_speech_utterance_id_preserves_structured_slots_into_intent_ir(self) -> None:
understanding = UnderstandingTranscriptProcessor(REPOSITORY_ROOT).process(
"把副驾腿托向上调整到最大位置"
).to_dict()
understanding["utterance_id"] = "e2e-speech-bridge"
class SpeechStub:
@staticmethod
def cached_result(utterance_id: str) -> dict[str, object]:
if utterance_id != "e2e-speech-bridge":
raise AssertionError("unexpected speech utterance id")
return understanding
original_speech = SeatDemoHandler.speech
SeatDemoHandler.speech = SpeechStub()
try:
request = urllib.request.Request(
f"{self.base_url}/api/requests",
method="POST",
headers={"Content-Type": "application/json", "Accept": "application/json"},
data=json.dumps(
{
"utterance": understanding["clean_text"],
"speech_utterance_id": "e2e-speech-bridge",
"client_type": "web_console",
"provider": "openai",
"conversation_id": "conv-e2e-speech-bridge",
"execution_mode": "dry_run",
},
ensure_ascii=False,
).encode("utf-8"),
)
with self.opener.open(request, timeout=3) as response:
payload = json.loads(response.read().decode("utf-8"))
finally:
SeatDemoHandler.speech = original_speech
self.assertEqual(payload["status"], "succeeded")
self.assertEqual(payload["provider_used"], "speech_semantic")
self.assertEqual(
payload["speech_understanding"]["raw_text"],
"把副驾腿托向上调整到最大位置",
)
action = payload["intent_ir"]["spec"]["actions"][0]
self.assertEqual(action["capability"], "seat.set_legrest_height")
self.assertEqual(action["target"]["instance"], "passenger")
self.assertEqual(action["parameters"], {"position_pct": 100})
self.assertEqual(action["provenance"]["parser"], "speech_semantic")
def test_roof_shade_speech_crosses_server_bridge_as_native_can(self) -> None:
cases = (
("把遮阳帘打开", {"motion": "open"}),
("遮阳帘打开一半", {"motion": "half"}),
)
for index, (utterance, parameters) in enumerate(cases):
with self.subTest(utterance=utterance):
utterance_id = f"e2e-roof-shade-{index}"
understanding = UnderstandingTranscriptProcessor(
REPOSITORY_ROOT
).process(utterance).to_dict()
understanding["utterance_id"] = utterance_id
def cached_result(
requested_id: str,
*,
expected_id: str = utterance_id,
result: dict[str, object] = understanding,
) -> dict[str, object]:
if requested_id != expected_id:
raise AssertionError("unexpected speech utterance id")
return result
original_speech = SeatDemoHandler.speech
SeatDemoHandler.speech = SimpleNamespace(cached_result=cached_result)
try:
request = urllib.request.Request(
f"{self.base_url}/api/requests",
method="POST",
headers={
"Content-Type": "application/json",
"Accept": "application/json",
},
data=json.dumps(
{
"utterance": understanding["clean_text"],
"speech_utterance_id": utterance_id,
"client_type": "web_console",
"provider": "openai",
"conversation_id": f"conv-e2e-roof-shade-{index}",
"execution_mode": "dry_run",
},
ensure_ascii=False,
).encode("utf-8"),
)
with self.opener.open(request, timeout=3) as response:
payload = json.loads(response.read().decode("utf-8"))
finally:
SeatDemoHandler.speech = original_speech
self.assertEqual(payload["status"], "succeeded")
self.assertEqual(payload["provider_used"], "speech_semantic")
self.assertEqual(payload["deferred_requests"], [])
action = payload["intent_ir"]["spec"]["actions"][0]
self.assertEqual(action["capability"], "roof.set_shade_motion")
self.assertEqual(action["target"]["zone"], "vehicle")
self.assertEqual(action["parameters"], parameters)
self.assertEqual(action["provenance"]["parser"], "speech_semantic")
def test_self_extension_vehicle_success_contract_is_strict(self) -> None:
with self.opener.open(
f"{self.base_url}/self-extension.js?v=extension-44", timeout=3
+50
View File
@@ -0,0 +1,50 @@
[
{"name":"single_power_on","raw":"打开空调。","clean":"打开空调。","capabilities":["climate.set_power"],"cancelled":0,"uncertain":false},
{"name":"single_power_off","raw":"关闭空调。","clean":"关闭空调。","capabilities":["climate.set_power"],"cancelled":0,"uncertain":false},
{"name":"single_temperature","raw":"把空调调到24度。","clean":"将空调设置为24度。","capabilities":["climate.set_temperature"],"cancelled":0,"uncertain":false},
{"name":"power_and_temperature","raw":"打开空调到24度。","clean":"打开空调并设置为24度。","capabilities":["climate.set_power","climate.set_temperature"],"cancelled":0,"uncertain":false},
{"name":"fan_level","raw":"把空调风量调到2档。","clean":"将空调风量设置为2档。","capabilities":["climate.set_fan_level"],"cancelled":0,"uncertain":false},
{"name":"backrest_relative","raw":"把座椅靠背向后调24度。","clean":"将座椅靠背向后调整24度。","capabilities":["seat.set_backrest_recline"],"cancelled":0,"uncertain":false},
{"name":"window_open","raw":"打开车窗。","clean":"打开车窗。","capabilities":["window.set_motion"],"cancelled":0,"uncertain":false},
{"name":"ambient_on","raw":"打开氛围灯。","clean":"打开氛围灯。","capabilities":["ambient_light.set_power"],"cancelled":0,"uncertain":false},
{"name":"seat_ventilation","raw":"打开座椅通风。","clean":"打开座椅通风。","capabilities":["seat.set_driver_ventilation_native"],"cancelled":0,"uncertain":false},
{"name":"two_commands","raw":"打开空调,然后打开氛围灯。","clean":"打开空调,然后打开氛围灯。","capabilities":["climate.set_power","ambient_light.set_power"],"cancelled":0,"uncertain":false},
{"name":"same_domain_scopes","raw":"打开前排空调,再打开后排空调。","clean":"打开前排空调,然后打开后排空调。","capabilities":["climate.set_power","climate.set_power"],"cancelled":0,"uncertain":false},
{"name":"fillers","raw":"呃,那个那个那个打开空调到24度。","clean":"打开空调并设置为24度。","capabilities":["climate.set_power","climate.set_temperature"],"cancelled":0,"uncertain":false},
{"name":"referential_demonstrative","raw":"打开那个车窗。","clean":"打开车窗。","capabilities":["window.set_motion"],"cancelled":0,"uncertain":false,"preserved_filler":"那个"},
{"name":"action_stutter","raw":"打打打开空调。","clean":"打开空调。","capabilities":["climate.set_power"],"cancelled":0,"uncertain":false},
{"name":"target_echo","raw":"把座椅的靠背靠座椅靠背向后调24度。","clean":"将座椅靠背向后调整24度。","capabilities":["seat.set_backrest_recline"],"cancelled":0,"uncertain":false},
{"name":"target_refinement","raw":"座椅调整到呃20呃靠背调整到24度。","clean":"将座椅靠背调整到24度。","capabilities":["seat.set_backrest_recline"],"cancelled":1,"uncertain":true},
{"name":"domain_correction","raw":"打开空调,不对,把座椅靠背向后调24度。","clean":"将座椅靠背向后调整24度。","capabilities":["seat.set_backrest_recline"],"cancelled":1,"uncertain":false},
{"name":"number_correction","raw":"空调调到24,不对,25度。","clean":"将空调设置为25度。","capabilities":["climate.set_temperature"],"cancelled":1,"uncertain":false},
{"name":"direction_correction","raw":"把座椅往前,不对,往后一点。","clean":"将座椅向后调整一点。","capabilities":["seat.set_driver_axis_native"],"cancelled":1,"uncertain":false},
{"name":"target_correction","raw":"把座椅,不对,把靠背往后调一点。","clean":"将座椅靠背向后调整一点。","capabilities":["seat.set_driver_axis_native"],"cancelled":1,"uncertain":false},
{"name":"action_correction","raw":"把空调关掉,不,是打开。","clean":"打开空调。","capabilities":["climate.set_power"],"cancelled":1,"uncertain":false},
{"name":"multi_not_correction","raw":"空调调到24度,然后把座椅靠背调到20度。","clean":"将空调设置为24度,然后将座椅靠背调整到20度。","capabilities":["climate.set_temperature","seat.set_backrest_recline"],"cancelled":0,"uncertain":true},
{"name":"cancel_incomplete","raw":"把空调调……算了。","clean":"","capabilities":[],"cancelled":1,"uncertain":false},
{"name":"negated_open","raw":"不要打开空调。","clean":"不要打开空调。","capabilities":[],"cancelled":1,"uncertain":false},
{"name":"negated_close","raw":"不要关闭空调。","clean":"不要关闭空调。","capabilities":[],"cancelled":1,"uncertain":false},
{"name":"filler_cancel","raw":"嗯嗯,那个……算了。","clean":"","capabilities":[],"cancelled":1,"uncertain":false},
{"name":"unrelated_chat","raw":"今天天气还不错。","clean":"","capabilities":[],"cancelled":0,"uncertain":false},
{"name":"unrelated_question","raw":"我们中午吃什么?","clean":"","capabilities":[],"cancelled":0,"uncertain":false},
{"name":"unknown_device","raw":"打开雨刷。","clean":"","capabilities":[],"cancelled":0,"uncertain":true},
{"name":"incomplete_known_target","raw":"空调挺凉快。","clean":"","capabilities":[],"cancelled":0,"uncertain":true},
{"name":"decimal_temperature","raw":"把空调调到24.5度。","clean":"将空调设置为24.5度。","capabilities":["climate.set_temperature"],"cancelled":0,"uncertain":false},
{"name":"chinese_number","raw":"把空调调到二十四度。","clean":"将空调设置为24度。","capabilities":["climate.set_temperature"],"cancelled":0,"uncertain":false},
{"name":"lost_marker_false_start","raw":"空调打开打,把座椅的靠背靠座椅靠背向后调24度。","clean":"将座椅靠背向后调整24度。","capabilities":["seat.set_backrest_recline"],"cancelled":1,"uncertain":false},
{"name":"polite_scaffold","raw":"请帮我打开空调,谢谢。","clean":"打开空调。","capabilities":["climate.set_power"],"cancelled":0,"uncertain":false},
{"name":"front_light_unsupported","raw":"打开前大灯","clean":"打开前大灯。","capabilities":[null],"cancelled":0,"uncertain":true,"execution_eligible":false},
{"name":"reading_ambient_color_compound","raw":"打开阅读灯,并且打开氛围灯,并把氛围灯变成红色","clean":"打开阅读灯,然后打开氛围灯,然后将氛围灯颜色调整为红色。","capabilities":["interior_light.set_reading_power","ambient_light.set_power","ambient_light.set_illumination"],"cancelled":0,"uncertain":true,"execution_eligible":false},
{"name":"window_half_open","raw":"打开一半车窗","clean":"将车窗打开到50%。","capabilities":["window.set_position"],"cancelled":0,"uncertain":false,"execution_eligible":true},
{"name":"passenger_legrest_maximum","raw":"把副驾腿托向上调整到最大位置","clean":"将副驾座椅腿托向上调整到100%。","capabilities":["seat.set_legrest_height"],"cancelled":0,"uncertain":false,"execution_eligible":true},
{"name":"driver_zero_gravity_unsupported","raw":"把主驾座椅调整为零重力模式","clean":"将主驾座椅调整为零重力模式。","capabilities":[null],"cancelled":0,"uncertain":true,"execution_eligible":false},
{"name":"shade_then_invalid_seat_degree","raw":"打开遮阳帘,然后把副驾座椅向后调整50度","clean":"打开遮阳帘,然后把副驾座椅向后调整50度。","capabilities":["roof.set_shade_motion","seat.set_longitudinal_position"],"parameter_numbers":[],"cancelled":0,"uncertain":true,"execution_eligible":false},
{"name":"window_quarter_open","raw":"打开四分之一车窗","clean":"将车窗打开到25%。","capabilities":["window.set_position"],"cancelled":0,"uncertain":false,"execution_eligible":true},
{"name":"passenger_thermal_pair_and_backrest_max","raw":"打开副驾座椅通风和加热,然后将副驾座椅靠背调整到最大位置","clean":"打开副驾座椅通风,然后打开副驾座椅加热,然后将副驾座椅靠背调整到100%。","capabilities":["seat.set_ventilation","seat.set_heating","seat.set_backrest_recline"],"cancelled":0,"uncertain":false,"execution_eligible":true},
{"name":"driver_thermal_pair_and_temperature","raw":"打开主驾座椅通风和加热,并且把空调调整为24度","clean":"打开主驾座椅通风,然后打开主驾座椅加热,然后将空调设置为24度。","capabilities":["seat.set_driver_ventilation_native","seat.set_driver_heating_native","climate.set_temperature"],"cancelled":0,"uncertain":false,"execution_eligible":true},
{"name":"thermal_reverse_order_connector","raw":"打开副驾座椅加热以及通风","clean":"打开副驾座椅加热,然后打开副驾座椅通风。","capabilities":["seat.set_heating","seat.set_ventilation"],"cancelled":0,"uncertain":false,"execution_eligible":true},
{"name":"window_three_quarters","raw":"打开四分之三车窗","clean":"将车窗打开到75%。","capabilities":["window.set_position"],"cancelled":0,"uncertain":false,"execution_eligible":true},
{"name":"shade_quarter_position","raw":"把遮阳帘打开到四分之一","clean":"将遮阳帘打开到25%。","capabilities":["roof.set_shade_position"],"cancelled":0,"uncertain":false,"execution_eligible":true},
{"name":"shade_open_bridge_regression","raw":"把遮阳帘打开","clean":"打开遮阳帘。","capabilities":["roof.set_shade_motion"],"cancelled":0,"uncertain":false,"execution_eligible":true},
{"name":"shade_half_open_bridge_regression","raw":"遮阳帘打开一半","clean":"将遮阳帘打开一半。","capabilities":["roof.set_shade_motion"],"cancelled":0,"uncertain":false,"execution_eligible":true}
]
@@ -0,0 +1,525 @@
from __future__ import annotations
import unittest
from pathlib import Path
from typing import Any
from cognitive_agent.application.native_can_intent import merge_native_can_intent
from cognitive_agent.application.native_can_runtime import NativeCanRuntime
from cognitive_agent.infrastructure.speech.capability import CapabilityResolver
from cognitive_agent.infrastructure.speech.understanding import (
UnderstandingTranscriptProcessor,
)
from cognitive_agent.infrastructure.speech.vocabulary import DeviceTerm
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
def _valid_value(spec: dict[str, Any]) -> Any:
allowed = spec.get("allowed_values")
if allowed:
return allowed[0]
value_type = str(spec.get("type", "string"))
if value_type in {"integer", "number"}:
minimum = spec.get("minimum", 0)
return int(minimum) if value_type == "integer" else float(minimum)
if value_type == "boolean":
return True
if value_type == "object":
return {}
if value_type == "array":
return []
if str(spec.get("name")) == "color_hex":
return "#FF0000"
if str(spec.get("name")) == "preset_name":
return "回归测试姿态"
return "test"
def _sample_parameters(definition: Any) -> dict[str, Any]:
specs = list(definition.parameters)
selected = [item for item in specs if item.get("required")]
if not selected and specs:
selected = [specs[0]]
return {str(item["name"]): _valid_value(item) for item in selected}
class SpeechCapabilityAlignmentTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.processor = UnderstandingTranscriptProcessor(REPOSITORY_ROOT)
cls.resolver = CapabilityResolver(REPOSITORY_ROOT)
cls.native_runtime = NativeCanRuntime(REPOSITORY_ROOT)
def test_eight_reported_cases_preserve_structure_and_resolution(self) -> None:
cases = (
{
"text": "把遮阳帘打开",
"status": "recognized_executable",
"clean": "打开遮阳帘。",
"commands": (
(
"roof.set_shade_motion",
"vehicle",
"roof.shade_motion",
"open",
{"motion": "open"},
),
),
},
{
"text": "打开前大灯",
"status": "recognized_unsupported",
"clean": "打开前大灯。",
"commands": ((None, "vehicle", "front_light.power", "on", {"state": "on"}),),
},
{
"text": "打开主驾座椅通风",
"status": "recognized_executable",
"clean": "打开主驾座椅通风。",
"commands": (
(
"seat.set_driver_ventilation_native",
"driver",
"seat.ventilation",
"on",
{"state": "on", "level": 2},
),
),
},
{
"text": "把主驾座椅调整为零重力模式",
"status": "recognized_unsupported",
"clean": "将主驾座椅调整为零重力模式。",
"commands": (
(None, "driver", "seat.mode", "apply", {"mode_profile_id": "zero_gravity"}),
),
},
{
"text": "打开一半车窗",
"status": "recognized_executable",
"clean": "将车窗打开到50%。",
"commands": (
(
"window.set_position",
"front_left",
"window.position",
"set",
{"position_pct": 50},
),
),
},
{
"text": "打开氛围灯并调整为红色",
"status": "incomplete",
"clean": "打开氛围灯,然后将氛围灯颜色调整为红色。",
"commands": (
("ambient_light.set_power", "vehicle", "ambient.power", "on", {"state": "on"}),
(
"ambient_light.set_illumination",
"vehicle",
"ambient.color",
"set",
{"color_hex": "#FF0000"},
),
),
},
{
"text": "打开主驾座椅加热",
"status": "recognized_executable",
"clean": "打开主驾座椅加热。",
"commands": (
(
"seat.set_driver_heating_native",
"driver",
"seat.heating",
"on",
{"state": "on", "level": 2},
),
),
},
{
"text": "打开副驾座椅通风",
"status": "recognized_executable",
"clean": "打开副驾座椅通风。",
"commands": (
(
"seat.set_ventilation",
"passenger",
"seat.ventilation",
"on",
{"state": "on"},
),
),
},
)
for case in cases:
with self.subTest(text=case["text"]):
result = self.processor.process(case["text"])
self.assertEqual(result.raw_text, case["text"])
self.assertEqual(result.clean_text, case["clean"])
self.assertEqual(result.semantic_status, case["status"])
self.assertEqual(len(result.commands), len(case["commands"]))
for command, expected in zip(result.commands, case["commands"], strict=True):
capability, target, property_name, operation, parameters = expected
self.assertEqual(command.capability, capability)
self.assertEqual(command.target_scope, target)
self.assertEqual(command.property_name, property_name)
self.assertEqual(command.operation, operation)
self.assertEqual(command.parameters, parameters)
if command.resolution_status == "executable":
self.assertIsNotNone(command.intent_ir_action)
self.assertIsNotNone(command.executor_payload)
self.assertEqual(command.intent_ir_action["target"]["zone"], target)
self.assertEqual(command.executor_payload["target"], target)
self.assertEqual(command.executor_payload["parameters"], parameters)
else:
self.assertIsNone(command.executor_payload)
def test_semantic_trace_covers_every_required_boundary(self) -> None:
result = self.processor.process("打开主驾座椅通风")
self.assertEqual(
[item["stage"] for item in result.semantic_trace],
[
"raw_text",
"normalized_text",
"clause_segmentation",
"semantic_commands",
"intent_ir",
"capability_resolution",
"executor_payload",
"clean_text",
],
)
def test_explicit_control_slots_cannot_disappear_from_clean_text(self) -> None:
cases = (
("打开主驾座椅加热", ("主驾", "座椅加热")),
("打开副驾座椅通风", ("副驾", "座椅通风")),
("打开右后车窗", ("右后", "车窗")),
("打开一半车窗", ("50%", "车窗")),
("把氛围灯调整为红色", ("氛围灯", "红色")),
("把主驾座椅调整为零重力模式", ("主驾", "零重力模式")),
)
for raw, preserved in cases:
with self.subTest(raw=raw):
result = self.processor.process(raw)
for token in preserved:
self.assertIn(token, result.clean_text)
def test_compound_shared_target_is_preserved_and_gated_atomically(self) -> None:
result = self.processor.process("打开氛围灯并调整为红色")
self.assertEqual(
[command.property_name for command in result.commands],
["ambient.power", "ambient.color"],
)
self.assertEqual(result.commands[1].value, "#FF0000")
self.assertEqual(result.commands[1].resolution_status, "incomplete")
self.assertIn("brightness_pct", "".join(result.commands[1].validation_issues))
self.assertFalse(result.execution_eligible)
complete = self.processor.process("把氛围灯调整为红色并将亮度设置为50%")
self.assertEqual(len(complete.commands), 1)
self.assertEqual(
complete.commands[0].parameters,
{"color_hex": "#FF0000", "brightness_pct": 50},
)
self.assertEqual(complete.semantic_status, "recognized_executable")
self.assertIn("红色", complete.clean_text)
self.assertIn("50%", complete.clean_text)
def test_reported_compound_and_extreme_position_phrases_preserve_all_slots(self) -> None:
compound = self.processor.process(
"打开阅读灯,并且打开氛围灯,并把氛围灯变成红色"
)
self.assertEqual(
[command.property_name for command in compound.commands],
["reading_light.power", "ambient.power", "ambient.color"],
)
self.assertEqual(compound.commands[-1].parameters, {"color_hex": "#FF0000"})
self.assertEqual(compound.semantic_status, "incomplete")
self.assertFalse(compound.execution_eligible)
for token in ("阅读灯", "氛围灯", "红色"):
self.assertIn(token, compound.clean_text)
legrest = self.processor.process("把副驾腿托向上调整到最大位置")
self.assertEqual(legrest.semantic_status, "recognized_executable")
self.assertTrue(legrest.execution_eligible)
self.assertEqual(legrest.commands[0].target_scope, "passenger")
self.assertEqual(legrest.commands[0].capability, "seat.set_legrest_height")
self.assertEqual(legrest.commands[0].parameters, {"position_pct": 100})
for token in ("副驾", "腿托", "向上", "100%"):
self.assertIn(token, legrest.clean_text)
def test_latest_reported_compounds_preserve_every_mutation(self) -> None:
passenger = self.processor.process(
"打开副驾座椅通风和加热,,然后将副驾座椅靠背调整到最大位置"
)
self.assertEqual(
[command.property_name for command in passenger.commands],
["seat.ventilation", "seat.heating", "seat.backrest_recline"],
)
self.assertEqual(
[command.target_scope for command in passenger.commands],
["passenger", "passenger", "passenger"],
)
self.assertEqual(passenger.commands[-1].parameters, {"position_pct": 100})
self.assertTrue(passenger.execution_eligible)
for token in ("副驾座椅通风", "副驾座椅加热", "副驾座椅靠背", "100%"):
self.assertIn(token, passenger.clean_text)
driver = self.processor.process(
"打开主驾座椅通风和加热,并且把空调调整为24度"
)
self.assertEqual(
[command.property_name for command in driver.commands],
["seat.ventilation", "seat.heating", "hvac.temperature"],
)
self.assertEqual(
[command.target_scope for command in driver.commands[:2]],
["driver", "driver"],
)
self.assertEqual(driver.commands[-1].parameters, {"temperature_c": 24})
self.assertTrue(driver.execution_eligible)
for token in ("主驾座椅通风", "主驾座椅加热", "24度"):
self.assertIn(token, driver.clean_text)
def test_coordinated_seat_properties_cover_order_connectors_and_actions(self) -> None:
cases = (
("打开主驾座椅通风与加热", "driver", ("seat.ventilation", "seat.heating"), ("on", "on")),
("打开副驾座椅加热以及通风", "passenger", ("seat.heating", "seat.ventilation"), ("on", "on")),
("关闭主驾座椅通风、加热", "driver", ("seat.ventilation", "seat.heating"), ("off", "off")),
("打开主驾座椅通风和关闭加热", "driver", ("seat.ventilation", "seat.heating"), ("on", "off")),
)
for text, scope, properties, operations in cases:
with self.subTest(text=text):
result = self.processor.process(text)
self.assertEqual(
tuple(command.property_name for command in result.commands),
properties,
)
self.assertEqual(
tuple(command.operation for command in result.commands),
operations,
)
self.assertEqual(
{command.target_scope for command in result.commands},
{scope},
)
self.assertTrue(result.execution_eligible)
self.assertTrue(all(command.executor_payload for command in result.commands))
def test_coordinated_targets_keep_local_actions_scopes_and_properties(self) -> None:
cases = (
(
"关闭空调和打开氛围灯",
(("hvac.power", "off", "vehicle"), ("ambient.power", "on", "vehicle")),
),
(
"打开主驾座椅通风和副驾座椅加热",
(("seat.ventilation", "on", "driver"), ("seat.heating", "on", "passenger")),
),
(
"打开前排和后排空调",
(("hvac.power", "on", "front_row"), ("hvac.power", "on", "rear_row")),
),
(
"打开主驾和副驾车窗",
(("window.motion", "open", "front_left"), ("window.motion", "open", "front_right")),
),
)
for text, expected in cases:
with self.subTest(text=text):
result = self.processor.process(text)
self.assertEqual(
tuple(
(command.property_name, command.operation, command.target_scope)
for command in result.commands
),
expected,
)
self.assertTrue(result.execution_eligible)
for command in result.commands:
self.assertEqual(
command.executor_payload["target"], command.target_scope
)
def test_ratio_semantics_are_shared_across_continuous_capabilities(self) -> None:
cases = (
("打开四分之一车窗", "window.position", {"position_pct": 25}, "25%"),
("打开四分之三车窗", "window.position", {"position_pct": 75}, "75%"),
("打开百分之三十车窗", "window.position", {"position_pct": 30}, "30%"),
("打开1/4车窗", "window.position", {"position_pct": 25}, "25%"),
("把遮阳帘打开到四分之一", "roof.shade_position", {"position_pct": 25}, "25%"),
("把副驾腿托调整到四分之三", "seat.legrest_height", {"position_pct": 75}, "75%"),
("把副驾座椅向后调整50%", "seat.longitudinal_position", {"position_pct": 50}, "50%"),
)
for text, property_name, parameters, display in cases:
with self.subTest(text=text):
result = self.processor.process(text)
self.assertEqual(len(result.commands), 1)
self.assertEqual(result.commands[0].property_name, property_name)
self.assertEqual(result.commands[0].parameters, parameters)
self.assertIn(display, result.clean_text)
self.assertTrue(result.execution_eligible)
def test_invalid_unit_keeps_all_spoken_slots_and_fails_closed(self) -> None:
raw = "打开遮阳帘,然后把副驾座椅向后调整50度"
result = self.processor.process(raw)
self.assertEqual(result.semantic_status, "incomplete")
self.assertFalse(result.execution_eligible)
self.assertEqual(result.commands[1].target_scope, "passenger")
self.assertEqual(result.commands[1].value, 50)
self.assertEqual(result.commands[1].parameters, {})
self.assertIsNone(result.commands[1].executor_payload)
for token in ("遮阳帘", "副驾", "座椅", "向后", "50度"):
self.assertIn(token, result.clean_text)
self.assertNotIn("将副驾座椅向后调整。", result.clean_text)
def test_shared_value_and_coordination_grammar_also_feed_one_line_parser(self) -> None:
window = merge_native_can_intent({}, "打开四分之一车窗")
self.assertEqual(
[
(action["capability"], action["target"], action["parameters"])
for action in window["native_can_actions"]
],
[("window.set_position", "front_left", {"position_pct": 25})],
)
thermal = merge_native_can_intent({}, "打开主驾座椅通风和加热")
self.assertEqual(
[action["capability"] for action in thermal["native_can_actions"]],
[
"seat.set_driver_ventilation_native",
"seat.set_driver_heating_native",
],
)
self.assertEqual(
[action["parameters"] for action in thermal["native_can_actions"]],
[
{"state": "on", "level": 2},
{"state": "on", "level": 2},
],
)
def test_shared_target_inherits_resolved_driver_scope_and_level_semantics(self) -> None:
result = self.processor.process("打开座椅通风并调到三档")
self.assertEqual(
[command.capability for command in result.commands],
[
"seat.set_driver_ventilation_native",
"seat.set_driver_ventilation_native",
],
)
self.assertEqual(
[command.target_scope for command in result.commands],
["driver", "driver"],
)
self.assertIn("3档", result.clean_text)
self.assertEqual(result.semantic_status, "recognized_unsupported")
self.assertFalse(result.execution_eligible)
passenger = self.processor.process("打开副驾座椅通风并调到三档")
self.assertEqual(
[command.target_scope for command in passenger.commands],
["passenger", "passenger"],
)
self.assertTrue(passenger.execution_eligible)
graded = self.processor.process("打开主驾座椅加热并调到高档")
self.assertEqual(graded.commands[-1].parameters["level"], 1)
self.assertIn("高档", graded.clean_text)
def test_unsupported_semantics_never_create_fake_executor_payloads(self) -> None:
for text in ("打开前大灯", "把主驾座椅调整为零重力模式"):
with self.subTest(text=text):
result = self.processor.process(text)
self.assertEqual(result.semantic_status, "recognized_unsupported")
self.assertTrue(result.clean_text)
self.assertTrue(result.commands)
self.assertTrue(
all(command.executor_payload is None for command in result.commands)
)
def test_zero_gravity_is_real_passenger_mode_but_not_driver_capability(self) -> None:
passenger = self.processor.process("打开零重力模式")
self.assertEqual(passenger.semantic_status, "recognized_executable")
self.assertEqual(passenger.commands[0].mode_profile_id, "zero_gravity")
payload = passenger.commands[0].executor_payload
self.assertIsNotNone(payload)
self.assertEqual(len(payload["operations"]), 4)
self.assertEqual(
{operation["target"] for operation in payload["operations"]},
{"passenger"},
)
driver = self.processor.process("把主驾座椅调整为零重力模式")
self.assertEqual(driver.semantic_status, "recognized_unsupported")
self.assertIsNone(driver.commands[0].executor_payload)
def test_native_executor_can_compile_reported_executable_cases(self) -> None:
for text in (
"把遮阳帘打开",
"打开主驾座椅通风",
"打开一半车窗",
"打开主驾座椅加热",
):
with self.subTest(text=text):
result = self.processor.process(text)
payloads = [command.executor_payload for command in result.commands]
self.assertTrue(all(payload is not None for payload in payloads))
config = self.native_runtime.build_function_config(
payloads[0]["domain"], payloads, "speech-alignment", "test"
)
plan = self.native_runtime.compile(config)
self.assertTrue(plan["spec"]["commands"])
def test_catalog_driven_resolution_matrix_covers_every_definition(self) -> None:
definitions = self.resolver.definitions()
self.assertEqual(len(definitions), 44)
projections = (
"canonical_action",
"target_preservation",
"parameter_preservation",
"intent_ir_projection",
"executor_projection",
)
for definition in definitions:
target = definition.target_scopes[0] if definition.target_scopes else "vehicle"
parameters = _sample_parameters(definition)
expected = (
"unsupported"
if definition.availability == "catalog_only"
or (
definition.availability == "native_can"
and definition.name not in self.native_runtime.supported_capabilities
)
else "executable"
)
for projection in projections:
with self.subTest(capability=definition.name, projection=projection):
result = self.resolver.resolve(
domain=definition.domain,
semantic_property=definition.name,
target_scope=target,
target_device=None,
operation="set",
parameters=parameters,
source_text=f"{projection}:{definition.name}:{target}",
preferred_capability=definition.name,
)
self.assertEqual(result.definition.name, definition.name)
self.assertEqual(result.status, expected)
if expected == "executable":
self.assertEqual(result.executor_payload["target"], target)
self.assertEqual(result.executor_payload["parameters"], parameters)
self.assertEqual(result.intent_ir_action["target"]["zone"], target)
else:
self.assertIsNone(result.executor_payload)
def test_vocabulary_does_not_define_vehicle_capabilities(self) -> None:
self.assertNotIn("capability", DeviceTerm.__dataclass_fields__)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,196 @@
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from unittest.mock import patch
import pytest
from cognitive_agent.application.seat_service import SeatDemoService
from cognitive_agent.infrastructure.speech.understanding import (
UnderstandingTranscriptProcessor,
)
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
@pytest.mark.parametrize(
("utterance", "capability", "target", "parameters"),
(
(
"打开一半车窗",
"window.set_position",
{"device": "window", "zone": "front_left"},
{"position_pct": 50},
),
(
"把副驾腿托向上调整到最大位置",
"seat.set_legrest_height",
{"device": "seat", "zone": "front_right", "instance": "passenger"},
{"position_pct": 100},
),
(
"把遮阳帘打开",
"roof.set_shade_motion",
{"device": "roof", "zone": "vehicle"},
{"motion": "open"},
),
(
"遮阳帘打开一半",
"roof.set_shade_motion",
{"device": "roof", "zone": "vehicle"},
{"motion": "half"},
),
(
"把遮阳帘打开到四分之一",
"roof.set_shade_position",
{"device": "roof", "zone": "vehicle"},
{"position_pct": 25},
),
),
)
def test_server_owned_speech_semantics_bypass_second_language_parse(
utterance: str,
capability: str,
target: dict[str, str],
parameters: dict[str, int],
) -> None:
processor = UnderstandingTranscriptProcessor(REPOSITORY_ROOT)
understanding = processor.process(utterance).to_dict()
understanding["utterance_id"] = "bridge-regression"
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 = SeatDemoService(REPOSITORY_ROOT)
service.artifact_root = Path(directory) / "artifacts"
with patch.object(
service,
"_interpret",
side_effect=AssertionError("cleanText must not be parsed a second time"),
):
result = service.process(
understanding["clean_text"],
provider="local",
execution_mode="dry_run",
_speech_understanding=understanding,
)
assert result["status"] == "succeeded"
assert result["provider_used"] == "speech_semantic"
assert result["speech_understanding"]["raw_text"] == utterance
actions = result["intent_ir"]["spec"]["actions"]
assert len(actions) == 1
assert actions[0]["capability"] == capability
assert actions[0]["target"] == target
assert actions[0]["parameters"] == parameters
assert actions[0]["provenance"]["parser"] == "speech_semantic"
def test_non_executable_speech_cannot_enter_orchestration_bridge() -> None:
processor = UnderstandingTranscriptProcessor(REPOSITORY_ROOT)
understanding = processor.process("打开前大灯").to_dict()
understanding["utterance_id"] = "bridge-unsupported"
with patch.dict(
os.environ,
{
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
},
clear=False,
):
service = SeatDemoService(REPOSITORY_ROOT)
context = service._context_for_vehicle(
service.conversations.snapshot("conv-speech-bridge"),
service.vehicle_profiles.get(None),
)
with pytest.raises(ValueError, match="not execution eligible"):
service._adapt_speech_understanding(
understanding,
context,
service.vehicle_profiles.get(None),
)
def test_every_registered_native_runtime_capability_keeps_native_bridge_binding() -> None:
def valid_value(spec: dict[str, object]) -> object:
allowed = spec.get("allowed_values")
if isinstance(allowed, list) and allowed:
return allowed[0]
value_type = str(spec.get("type", "string"))
if value_type in {"integer", "number"}:
return spec.get("minimum", 0)
if value_type == "boolean":
return True
if value_type == "object":
return {}
if value_type == "array":
return []
return "test"
with patch.dict(
os.environ,
{
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
},
clear=False,
):
service = SeatDemoService(REPOSITORY_ROOT)
context = service._context_for_vehicle(
service.conversations.snapshot("conv-native-speech-matrix"),
service.vehicle_profiles.get(None),
)
registered = {
definition.name: definition
for definition in service.capability_registry.definitions()
if definition.name in service.native_can_runtime.supported_capabilities
}
assert registered
for capability, definition in registered.items():
parameters = {
str(spec["name"]): valid_value(spec)
for spec in definition.parameters
if spec.get("required")
}
target = definition.target_scopes[0]
understanding = {
"raw_text": f"native bridge matrix: {capability}",
"execution_eligible": True,
"commands": [
{
"status": "active",
"negated": False,
"resolution_status": "executable",
"validation_issues": [],
"confidence": 1.0,
"source_text": capability,
"executor_payload": {
"domain": definition.domain,
"capability": capability,
"target": target,
"operation": "set",
"parameters": parameters,
},
}
],
}
parsed = service._adapt_speech_understanding(
understanding,
context,
service.vehicle_profiles.get(None),
)
assert parsed["deferred_requests"] == [], capability
assert parsed["actions"] == [], capability
assert parsed["domain_actions"] == [], capability
assert [
action["capability"] for action in parsed["native_can_actions"]
] == [capability]
+190
View File
@@ -0,0 +1,190 @@
from __future__ import annotations
import io
import json
import math
import tempfile
import unittest
import wave
from pathlib import Path
from types import SimpleNamespace
from cognitive_agent.infrastructure.speech.sensevoice import (
SpeechRecognitionError,
SpeechTranscriber,
)
from cognitive_agent.infrastructure.speech.understanding import (
UnderstandingTranscriptProcessor,
)
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
def pcm16_wav(*, frequency: float = 220, seconds: float = 0.8) -> bytes:
frames = bytearray()
for index in range(round(16_000 * seconds)):
sample = round(math.sin(index * 2 * math.pi * frequency / 16_000) * 12_000)
frames.extend(int(sample).to_bytes(2, "little", signed=True))
target = io.BytesIO()
with wave.open(target, "wb") as output:
output.setnchannels(1)
output.setsampwidth(2)
output.setframerate(16_000)
output.writeframes(frames)
return target.getvalue()
class _FakeStream:
def __init__(self, text: str) -> None:
self.result = SimpleNamespace(
text=f"<|zh|><|NEUTRAL|><|Speech|>{text}",
lang="<|zh|>",
emotion="<|NEUTRAL|>",
event="<|Speech|>",
)
def accept_waveform(self, sample_rate: int, samples: object) -> None:
self.sample_rate = sample_rate
self.samples = samples
class _FakeRecognizer:
def __init__(self, text: str) -> None:
self.text = text
self.decode_count = 0
def create_stream(self) -> _FakeStream:
return _FakeStream(self.text)
def decode_stream(self, stream: _FakeStream) -> None:
self.decode_count += 1
class UnderstandingRegressionTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.processor = UnderstandingTranscriptProcessor(REPOSITORY_ROOT)
fixture = REPOSITORY_ROOT / "tests" / "fixtures" / "speech_text_regression.json"
cls.cases = json.loads(fixture.read_text(encoding="utf-8"))
def test_text_regression_suite(self) -> None:
for case in self.cases:
with self.subTest(case=case["name"]):
result = self.processor.process(case["raw"])
self.assertEqual(result.raw_text, case["raw"])
self.assertEqual(result.clean_text, case["clean"])
self.assertEqual(
[command.capability for command in result.commands],
case["capabilities"],
)
self.assertEqual(len(result.cancelled_fragments), case["cancelled"])
self.assertEqual(result.uncertain, case["uncertain"])
self.assertEqual(
result.execution_eligible,
case.get(
"execution_eligible",
bool(result.commands) and not result.uncertain,
),
)
if preserved := case.get("preserved_filler"):
self.assertNotIn(preserved, result.removed_fillers)
def test_cancelled_commands_never_appear_as_active(self) -> None:
result = self.processor.process(
"打开空调,不对,把座椅靠背向后调24度。"
)
self.assertEqual([command.domain for command in result.commands], ["seat"])
cancelled = result.cancelled_fragments[0].commands
self.assertEqual([command.domain for command in cancelled], ["climate"])
self.assertTrue(all(command.status == "cancelled" for command in cancelled))
def test_invalid_seat_absolute_angle_fails_closed(self) -> None:
result = self.processor.process("把座椅靠背调整到24度。")
self.assertTrue(result.uncertain)
self.assertFalse(result.execution_eligible)
self.assertIn("低于工程下限95", "".join(result.reasons))
def test_regular_multi_command_is_not_treated_as_correction(self) -> None:
result = self.processor.process("打开空调,然后打开氛围灯。")
self.assertEqual(len(result.commands), 2)
self.assertFalse(result.cancelled_fragments)
class SpeechTranscriberTests(unittest.TestCase):
def make_transcriber(self, directory: str, text: str) -> tuple[SpeechTranscriber, _FakeRecognizer]:
transcriber = SpeechTranscriber(Path(directory))
recognizer = _FakeRecognizer(text)
transcriber._recognizer = recognizer
return transcriber, recognizer
def test_transcriber_preserves_raw_and_returns_structured_result(self) -> None:
raw = "打开空调,不对,把座椅靠背向后调24度。"
with tempfile.TemporaryDirectory() as directory:
transcriber, _ = self.make_transcriber(directory, raw)
result = transcriber.transcribe_wav(
pcm16_wav(), utterance_id="unit-structured"
)
self.assertEqual(result["raw_text"], raw)
self.assertEqual(result["clean_text"], "将座椅靠背向后调整24度。")
self.assertEqual(result["commands"][0]["domain"], "seat")
self.assertEqual(result["cancelled_fragments"][0]["commands"][0]["domain"], "climate")
self.assertIn("timings", result)
self.assertEqual(result["segments"][0]["state"], "final")
def test_filler_only_is_evidence_but_not_a_control_error(self) -> None:
with tempfile.TemporaryDirectory() as directory:
transcriber, _ = self.make_transcriber(directory, "嗯嗯。")
result = transcriber.transcribe_wav(
pcm16_wav(), utterance_id="unit-filler"
)
self.assertEqual(result["raw_text"], "嗯嗯。")
self.assertEqual(result["clean_text"], "")
self.assertEqual(result["commands"], [])
self.assertFalse(result["execution_eligible"])
def test_same_utterance_and_audio_is_decoded_once(self) -> None:
audio = pcm16_wav()
with tempfile.TemporaryDirectory() as directory:
transcriber, recognizer = self.make_transcriber(directory, "打开空调。")
first = transcriber.transcribe_wav(audio, utterance_id="unit-idempotent")
second = transcriber.transcribe_wav(audio, utterance_id="unit-idempotent")
self.assertEqual(recognizer.decode_count, 1)
self.assertFalse(first["idempotent_replay"])
self.assertTrue(second["idempotent_replay"])
def test_cached_result_is_server_owned_and_returned_as_a_copy(self) -> None:
with tempfile.TemporaryDirectory() as directory:
transcriber, _ = self.make_transcriber(directory, "打开空调。")
transcriber.transcribe_wav(pcm16_wav(), utterance_id="unit-cached")
first = transcriber.cached_result("unit-cached")
first["clean_text"] = "tampered"
second = transcriber.cached_result("unit-cached")
self.assertNotEqual(second["clean_text"], "tampered")
def test_missing_cached_result_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as directory:
transcriber, _ = self.make_transcriber(directory, "打开空调。")
with self.assertRaises(SpeechRecognitionError) as caught:
transcriber.cached_result("unit-expired")
self.assertEqual(caught.exception.code, "speech_result_expired")
def test_same_utterance_with_different_audio_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as directory:
transcriber, _ = self.make_transcriber(directory, "打开空调。")
transcriber.transcribe_wav(pcm16_wav(), utterance_id="unit-conflict")
with self.assertRaises(SpeechRecognitionError) as caught:
transcriber.transcribe_wav(
pcm16_wav(frequency=330), utterance_id="unit-conflict"
)
self.assertEqual(caught.exception.code, "utterance_id_conflict")
def test_invalid_utterance_id_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as directory:
transcriber, _ = self.make_transcriber(directory, "打开空调。")
with self.assertRaises(SpeechRecognitionError) as caught:
transcriber.transcribe_wav(pcm16_wav(), utterance_id="bad id/路径")
self.assertEqual(caught.exception.code, "invalid_utterance_id")
if __name__ == "__main__":
unittest.main()
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
import unittest
from cognitive_agent.infrastructure.speech.assembler import TranscriptAssembler
from cognitive_agent.infrastructure.speech.contracts import SpeechSegmentResult
def segment(
segment_id: str,
sequence: int,
text: str,
*,
final: bool,
start_ms: int | None = None,
end_ms: int | None = None,
overlap_ms: int = 0,
) -> SpeechSegmentResult:
start = sequence * 1000 if start_ms is None else start_ms
end = start + 1200 if end_ms is None else end_ms
return SpeechSegmentResult(
utterance_id="utterance-1",
segment_id=segment_id,
sequence=sequence,
start_ms=start,
end_ms=end,
text=text,
is_final=final,
audio_overlap_ms=overlap_ms,
)
class TranscriptAssemblerTests(unittest.TestCase):
def test_partial_replaces_partial_and_final_commits(self) -> None:
assembler = TranscriptAssembler("utterance-1")
assembler.update(segment("s0", 0, "打开空", final=False))
self.assertEqual(assembler.snapshot().text, "打开空")
assembler.update(segment("s0", 0, "打开空调", final=False))
self.assertEqual(assembler.snapshot().text, "打开空调")
final = assembler.update(segment("s0", 0, "打开空调。", final=True))
self.assertEqual(final.text, "打开空调。")
self.assertFalse(final.partial_segments)
def test_duplicate_final_callback_is_idempotent(self) -> None:
assembler = TranscriptAssembler("utterance-1")
callback = segment("s0", 0, "打开空调。", final=True)
assembler.update(callback)
result = assembler.update(callback)
self.assertEqual(result.text, "打开空调。")
self.assertEqual(result.duplicate_callbacks, 1)
def test_final_cannot_be_mutated(self) -> None:
assembler = TranscriptAssembler("utterance-1")
assembler.update(segment("s0", 0, "打开空调。", final=True))
result = assembler.update(segment("s0", 0, "关闭空调。", final=True))
self.assertTrue(result.uncertain)
self.assertEqual(result.text, "打开空调。")
def test_out_of_order_callbacks_are_sorted(self) -> None:
assembler = TranscriptAssembler("utterance-1")
assembler.update(segment("s1", 1, "打开氛围灯。", final=True))
result = assembler.update(segment("s0", 0, "打开空调。", final=True))
self.assertEqual(result.text, "打开空调。打开氛围灯。")
self.assertEqual(result.out_of_order_callbacks, 1)
def test_audio_supported_boundary_overlap_is_removed(self) -> None:
assembler = TranscriptAssembler("utterance-1")
assembler.update(
segment("s0", 0, "把座椅靠背", final=True, start_ms=0, end_ms=1500)
)
result = assembler.update(
segment(
"s1",
1,
"靠背向后调24度",
final=True,
start_ms=1400,
end_ms=2600,
overlap_ms=100,
)
)
self.assertEqual(result.text, "把座椅靠背,向后调24度")
self.assertEqual(result.overlaps_removed, ("靠背",))
def test_long_text_overlap_is_removed_without_audio_overlap(self) -> None:
assembler = TranscriptAssembler("utterance-1")
assembler.update(segment("s0", 0, "请打开空调温度", final=True))
result = assembler.update(segment("s1", 1, "空调温度调到24度", final=True))
self.assertIn("空调温度", result.overlaps_removed)
def test_repeated_action_in_distinct_commands_is_preserved(self) -> None:
assembler = TranscriptAssembler("utterance-1")
assembler.update(segment("s0", 0, "打开前排空调", final=True))
result = assembler.update(segment("s1", 1, "打开后排空调", final=True))
self.assertEqual(result.text, "打开前排空调,打开后排空调")
self.assertFalse(result.overlaps_removed)
def test_finish_rejects_late_callbacks_and_reset_starts_new_utterance(self) -> None:
assembler = TranscriptAssembler("utterance-1")
assembler.update(segment("s0", 0, "打开空调", final=True))
assembler.finish()
late = assembler.update(segment("s1", 1, "关闭空调", final=True))
self.assertEqual(late.text, "打开空调")
assembler.reset("utterance-2")
self.assertEqual(assembler.snapshot().utterance_id, "utterance-2")
self.assertEqual(assembler.snapshot().text, "")
if __name__ == "__main__":
unittest.main()
+288
View File
@@ -0,0 +1,288 @@
"""Measure text semantics and optional real/synthetic WAV speech regressions."""
from __future__ import annotations
import argparse
import json
import re
import statistics
import time
from pathlib import Path
from typing import Any
from cognitive_agent.infrastructure.speech.sensevoice import SpeechTranscriber
from cognitive_agent.infrastructure.speech.understanding import (
UnderstandingTranscriptProcessor,
)
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
TEXT_FIXTURE = REPOSITORY_ROOT / "tests" / "fixtures" / "speech_text_regression.json"
DEFAULT_AUDIO_DIR = REPOSITORY_ROOT / "artifacts" / "tmp" / "speech-regression-audio"
NUMBER = re.compile(r"-?\d+(?:\.\d+)?")
CAPABILITY_ALIGNMENT_TEXTS = (
"把遮阳帘打开",
"打开前大灯",
"打开主驾座椅通风",
"把主驾座椅调整为零重力模式",
"打开一半车窗",
"打开氛围灯并调整为红色",
"打开主驾座椅加热",
"打开副驾座椅通风",
"打开遮阳帘,然后把副驾座椅向后调整50度",
"打开四分之一车窗",
"打开副驾座椅通风和加热,然后将副驾座椅靠背调整到最大位置",
"打开主驾座椅通风和加热,并且把空调调整为24度",
"打开副驾座椅加热以及通风",
"关闭空调和打开氛围灯",
"打开前排和后排空调",
"打开主驾和副驾车窗",
)
AUDIO_EXPECTATIONS: dict[str, dict[str, Any]] = {
"01_filler_hvac.wav": {
"raw": "呃,那个那个那个打开空调到24度。",
"clean": "打开空调并设置为24度。",
"capabilities": ["climate.set_power", "climate.set_temperature"],
},
"02_seat_refine.wav": {
"raw": "座椅调整到呃20呃,靠背调整到24度。",
"clean": "将座椅靠背调整到24度。",
"capabilities": ["seat.set_backrest_recline"],
},
"03_domain_repair.wav": {
"raw": "打开空调,不对,把座椅靠背向后调24度。",
"clean": "将座椅靠背向后调整24度。",
"capabilities": ["seat.set_backrest_recline"],
},
"04_multi.wav": {
"raw": "空调调到24度,然后把座椅靠背调到20度。",
"clean": "将空调设置为24度,然后将座椅靠背调整到20度。",
"capabilities": ["climate.set_temperature", "seat.set_backrest_recline"],
},
"05_number_repair.wav": {
"raw": "空调调到24,不对,25度。",
"clean": "将空调设置为25度。",
"capabilities": ["climate.set_temperature"],
},
"06_direction_repair.wav": {
"raw": "把座椅往前,不对,往后一点。",
"clean": "将座椅向后调整一点。",
"capabilities": ["seat.set_driver_axis_native"],
},
"07_negation.wav": {"raw": "不要打开空调。", "clean": "不要打开空调。", "capabilities": []},
"08_chat.wav": {"raw": "今天天气不错。", "clean": "", "capabilities": []},
"09_fast.wav": {
"raw": "打开空调,然后把座椅靠背向后调24度。",
"clean": "打开空调,然后将座椅靠背向后调整24度。",
"capabilities": ["climate.set_power", "seat.set_backrest_recline"],
},
"10_slow.wav": {
"raw": "打开空调,然后把座椅靠背向后调24度。",
"clean": "打开空调,然后将座椅靠背向后调整24度。",
"capabilities": ["climate.set_power", "seat.set_backrest_recline"],
},
}
def ratio(passed: int, total: int) -> float:
return round(passed / total, 4) if total else 0.0
def percentile(values: list[float], quantile: float) -> float:
if not values:
return 0.0
ordered = sorted(values)
index = round((len(ordered) - 1) * quantile)
return round(ordered[index], 3)
def numeric_parameters(commands: list[dict[str, Any]]) -> list[float]:
return sorted(
float(value)
for command in commands
for name, value in command.get("parameters", {}).items()
if name not in set(command.get("qualifiers", {}).get("implicit_parameters", ()))
if isinstance(value, (int, float)) and not isinstance(value, bool)
)
def expected_numbers(clean: str) -> list[float]:
return sorted(float(value) for value in NUMBER.findall(clean))
def score_cases(cases: list[dict[str, Any]]) -> dict[str, Any]:
def domains(capabilities: list[str | None]) -> set[str]:
return {
capability.split(".", 1)[0]
for capability in capabilities
if capability
}
total = len(cases)
def parameter_numbers(item: dict[str, Any]) -> list[float]:
return [
float(value)
for value in item.get("parameter_numbers", expected_numbers(item["clean"]))
]
semantic = sum(item["actual_clean"] == item["clean"] for item in cases)
intent = sum(item["actual_capabilities"] == item["capabilities"] for item in cases)
device = sum(
domains(item["actual_capabilities"]) == domains(item["capabilities"])
for item in cases
)
parameter = sum(item["actual_numbers"] == parameter_numbers(item) for item in cases)
corrections = [item for item in cases if item.get("cancelled", 0)]
correction_success = sum(
item["actual_capabilities"] == item["capabilities"]
and item["actual_cancelled"] >= item.get("cancelled", 0)
for item in corrections
)
no_command = [item for item in cases if not item["capabilities"]]
false_activation = sum(bool(item["actual_capabilities"]) for item in no_command)
wrong_device = sum(
domains(item["actual_capabilities"]) != domains(item["capabilities"])
for item in cases
)
wrong_number = sum(item["actual_numbers"] != parameter_numbers(item) for item in cases)
semantic_times = [item["semantic_ms"] for item in cases]
resolution_times = [item["command_resolution_ms"] for item in cases]
return {
"case_count": total,
"semantic_clean_accuracy": ratio(semantic, total),
"intent_exact_match": ratio(intent, total),
"device_accuracy": ratio(device, total),
"action_accuracy": ratio(intent, total),
"parameter_exact_match": ratio(parameter, total),
"correction_success_rate": ratio(correction_success, len(corrections)),
"false_command_activation_rate": ratio(false_activation, len(no_command)),
"severe_wrong_device_count": wrong_device,
"severe_wrong_number_count": wrong_number,
"semantic_processing_p50_ms": percentile(semantic_times, 0.50),
"semantic_processing_p95_ms": percentile(semantic_times, 0.95),
"command_resolution_p50_ms": percentile(resolution_times, 0.50),
"command_resolution_p95_ms": percentile(resolution_times, 0.95),
}
def run_text() -> tuple[dict[str, Any], list[dict[str, Any]]]:
processor = UnderstandingTranscriptProcessor(REPOSITORY_ROOT)
fixtures = json.loads(TEXT_FIXTURE.read_text(encoding="utf-8"))
cases: list[dict[str, Any]] = []
for fixture in fixtures:
started = time.perf_counter()
result = processor.process(fixture["raw"])
semantic_ms = (time.perf_counter() - started) * 1000
commands = [command.to_dict() for command in result.commands]
cases.append(
{
**fixture,
"actual_clean": result.clean_text,
"actual_capabilities": [command["capability"] for command in commands],
"actual_numbers": numeric_parameters(commands),
"actual_cancelled": len(result.cancelled_fragments),
"semantic_ms": semantic_ms,
"command_resolution_ms": result.command_resolution_ms,
}
)
return score_cases(cases), cases
def run_audio(audio_dir: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]:
transcriber = SpeechTranscriber(REPOSITORY_ROOT)
cases: list[dict[str, Any]] = []
for name, expected in AUDIO_EXPECTATIONS.items():
path = audio_dir / name
if not path.is_file():
continue
wall_started = time.perf_counter()
result = transcriber.transcribe_wav(
path.read_bytes(), utterance_id=f"benchmark-{path.stem}"
)
wall_ms = (time.perf_counter() - wall_started) * 1000
commands = list(result["commands"])
cases.append(
{
"name": name,
**expected,
"cancelled": 1 if "repair" in name or "refine" in name else 0,
"actual_raw": result["raw_text"],
"actual_clean": result["clean_text"],
"actual_capabilities": [command["capability"] for command in commands],
"actual_numbers": numeric_parameters(commands),
"actual_cancelled": len(result["cancelled_fragments"]),
"semantic_ms": float(result["timings"]["semantic_ms"]),
"command_resolution_ms": float(result["timings"]["command_resolution_ms"]),
"asr_ms": float(result["timings"]["asr_ms"]),
"end_to_end_ms": float(result["timings"]["total_ms"]),
"wall_ms": wall_ms,
"duplicate_callbacks": int(result["assembly"]["duplicate_callbacks"]),
}
)
metrics = score_cases(cases)
raw_exact = sum(item["actual_raw"] == item["raw"] for item in cases)
metrics.update(
{
"raw_transcription_accuracy": ratio(raw_exact, len(cases)),
"duplicate_transcript_rate": ratio(
sum(item["duplicate_callbacks"] > 0 for item in cases), len(cases)
),
"asr_inference_p50_ms": percentile([item["asr_ms"] for item in cases], 0.50),
"asr_inference_p95_ms": percentile([item["asr_ms"] for item in cases], 0.95),
"end_to_end_p50_ms": percentile([item["end_to_end_ms"] for item in cases], 0.50),
"end_to_end_p95_ms": percentile([item["end_to_end_ms"] for item in cases], 0.95),
"cold_start_end_to_end_ms": round(cases[0]["end_to_end_ms"], 3) if cases else 0.0,
"steady_state_end_to_end_p50_ms": percentile(
[item["end_to_end_ms"] for item in cases[1:]], 0.50
),
"steady_state_end_to_end_p95_ms": percentile(
[item["end_to_end_ms"] for item in cases[1:]], 0.95
),
"wall_mean_ms": round(statistics.fmean(item["wall_ms"] for item in cases), 3)
if cases
else 0.0,
}
)
return metrics, cases
def run_capability_resolution(*, iterations: int = 100) -> dict[str, float | int]:
processor = UnderstandingTranscriptProcessor(REPOSITORY_ROOT)
semantic_times: list[float] = []
resolution_times: list[float] = []
for _ in range(iterations):
for text in CAPABILITY_ALIGNMENT_TEXTS:
result = processor.process(text)
semantic_times.append(result.processing_ms)
resolution_times.append(result.command_resolution_ms)
return {
"sample_count": len(semantic_times),
"semantic_p50_ms": percentile(semantic_times, 0.50),
"semantic_p95_ms": percentile(semantic_times, 0.95),
"command_resolution_p50_ms": percentile(resolution_times, 0.50),
"command_resolution_p95_ms": percentile(resolution_times, 0.95),
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--audio-dir", type=Path, default=DEFAULT_AUDIO_DIR)
parser.add_argument("--details", action="store_true")
args = parser.parse_args()
text_metrics, text_cases = run_text()
payload: dict[str, Any] = {
"text": text_metrics,
"capability_alignment": run_capability_resolution(),
}
if args.audio_dir.is_dir():
audio_metrics, audio_cases = run_audio(args.audio_dir)
payload["audio"] = audio_metrics
if args.details:
payload["audio_cases"] = audio_cases
if args.details:
payload["text_cases"] = text_cases
print(json.dumps(payload, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
+106
View File
@@ -0,0 +1,106 @@
"""Download official SenseVoiceSmall and Silero VAD sherpa-onnx models."""
from __future__ import annotations
import argparse
import hashlib
import shutil
import tarfile
import tempfile
import urllib.request
from pathlib import Path
MODEL_URL = (
"https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/"
"sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17.tar.bz2"
)
MODEL_SHA256 = "f6b2a72ebcb1ac7a764d4cfccd886e6bcb2a95c4657c2199d0ba95ed4b9ea71a"
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_TARGET = REPOSITORY_ROOT / "artifacts" / "models" / "sensevoice-small"
VAD_URL = (
"https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/"
"silero_vad.onnx"
)
VAD_SHA256 = "9e2449e1087496d8d4caba907f23e0bd3f78d91fa552479bb9c23ac09cbb1fd6"
DEFAULT_VAD_TARGET = (
REPOSITORY_ROOT / "artifacts" / "models" / "silero-vad" / "silero_vad.onnx"
)
def _download(url: str, destination: Path) -> None:
with urllib.request.urlopen(url, timeout=60) as response, destination.open("wb") as output:
shutil.copyfileobj(response, output, length=1024 * 1024)
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def install(target: Path) -> None:
target = target.resolve()
if (target / "model.onnx").is_file() and (target / "tokens.txt").is_file():
print(f"SenseVoiceSmall is already installed at {target}")
return
target.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="sensevoice-install-") as temporary_directory:
temporary_root = Path(temporary_directory)
archive = temporary_root / "sensevoice-small.tar.bz2"
print(f"Downloading the official SenseVoiceSmall model to {archive} ...", flush=True)
_download(MODEL_URL, archive)
actual_hash = _sha256(archive)
if actual_hash != MODEL_SHA256:
raise RuntimeError(
f"SenseVoiceSmall archive checksum mismatch: {actual_hash} != {MODEL_SHA256}"
)
extracted = temporary_root / "extracted"
extracted.mkdir()
with tarfile.open(archive, "r:bz2") as package:
package.extractall(extracted, filter="data")
model_file = next(extracted.rglob("model.onnx"), None)
if model_file is None or not (model_file.parent / "tokens.txt").is_file():
raise RuntimeError("SenseVoiceSmall archive does not contain the expected model files")
for item in model_file.parent.iterdir():
destination = target / item.name
if item.is_dir():
shutil.copytree(item, destination, dirs_exist_ok=True)
else:
shutil.copy2(item, destination)
print(f"SenseVoiceSmall installed at {target}")
def install_vad(target: Path) -> None:
target = target.resolve()
if target.is_file() and _sha256(target) == VAD_SHA256:
print(f"Silero VAD is already installed at {target}")
return
target.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="silero-vad-install-") as temporary_directory:
downloaded = Path(temporary_directory) / "silero_vad.onnx"
print(f"Downloading the official Silero VAD model to {downloaded} ...", flush=True)
_download(VAD_URL, downloaded)
actual_hash = _sha256(downloaded)
if actual_hash != VAD_SHA256:
raise RuntimeError(
f"Silero VAD checksum mismatch: {actual_hash} != {VAD_SHA256}"
)
shutil.copy2(downloaded, target)
print(f"Silero VAD installed at {target}")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--target", type=Path, default=DEFAULT_TARGET)
parser.add_argument("--vad-target", type=Path, default=DEFAULT_VAD_TARGET)
parser.add_argument("--skip-vad", action="store_true")
args = parser.parse_args()
install(args.target)
if not args.skip_vad:
install_vad(args.vad_target)
if __name__ == "__main__":
main()
+88
View File
@@ -0,0 +1,88 @@
"""Print the capability/executor matrix derived from repository catalogs."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
from cognitive_agent.infrastructure.speech.capability import CapabilityResolver
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
def parameter_summary(parameters: tuple[dict[str, Any], ...]) -> str:
values: list[str] = []
for parameter in parameters:
name = str(parameter["name"])
value_type = str(parameter.get("type", "unknown"))
allowed = parameter.get("allowed_values")
if allowed is not None:
constraint = "{" + ",".join(map(str, allowed)) + "}"
elif parameter.get("minimum") is not None or parameter.get("maximum") is not None:
constraint = f"[{parameter.get('minimum', '-∞')},{parameter.get('maximum', '+∞')}]"
else:
constraint = ""
unit = f" {parameter['unit']}" if parameter.get("unit") else ""
optional = "" if parameter.get("required") else "?"
values.append(f"{name}{optional}:{value_type}{constraint}{unit}")
return "; ".join(values) or "—"
def matrix() -> list[dict[str, Any]]:
resolver = CapabilityResolver(REPOSITORY_ROOT)
rows = []
for definition in resolver.definitions():
executable = not (
definition.availability == "catalog_only"
or (
definition.availability == "native_can"
and definition.name not in resolver.native_supported
)
)
rows.append(
{
"device": ",".join(definition.target_devices) or "—",
"target": ",".join(definition.target_scopes) or "—",
"capability": definition.name,
"operation": "set/defined by capability parameters",
"value_schema": parameter_summary(definition.parameters),
"availability": definition.availability,
"execution_eligible": executable,
"executor_binding": (
resolver.executor_binding_for(definition) if executable else None
),
"catalog": str(definition.catalog_path.relative_to(REPOSITORY_ROOT)),
}
)
return rows
def as_markdown(rows: list[dict[str, Any]]) -> str:
lines = [
"| Device | Target / scope | Capability | Value type / range / unit | Executable | Executor binding |",
"|---|---|---|---|---:|---|",
]
for row in rows:
lines.append(
"| {device} | {target} | `{capability}` | {value_schema} | {execution_eligible} | {executor_binding} |".format(
**row
)
)
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--format", choices=("json", "markdown"), default="markdown")
args = parser.parse_args()
rows = matrix()
if args.format == "json":
print(json.dumps(rows, ensure_ascii=False, indent=2))
else:
print(as_markdown(rows))
if __name__ == "__main__":
main()