diff --git a/.gitignore b/.gitignore index 304a00e..c1441f3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ artifacts/mode-profiles/ artifacts/server/ artifacts/tmp/ artifacts/self-repair/ +artifacts/models/ artifacts/*.log tmp/*.log .codex_tmp/ diff --git a/README.md b/README.md index 06b6b75..387b42b 100755 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/web-console/README.md b/apps/web-console/README.md index c016749..f98faac 100755 --- a/apps/web-console/README.md +++ b/apps/web-console/README.md @@ -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 diff --git a/apps/web-console/app.js b/apps/web-console/app.js index 2767b36..eb98b52 100755 --- a/apps/web-console/app.js +++ b/apps/web-console/app.js @@ -7453,7 +7453,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) { @@ -7461,6 +7468,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" @@ -7491,12 +7512,15 @@ async function submitRequest(event) { const requestBody = { utterance, client_type: "web_console", - provider: state.provider, + provider: requestProvider, conversation_id: state.conversationId, vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id, execution_mode: state.executionMode, control_transport: state.controlTransport, - ...(state.provider === "openai" ? { reasoning_effort: state.reasoningEffort } : {}), + ...(requestProvider === "openai" ? { reasoning_effort: state.reasoningEffort } : {}), + ...(speechSubmission + ? { speech_utterance_id: utteranceInput.dataset.speechUtteranceId } + : {}), ...(state.executionMode === "hardware" && state.controlTransport !== "wireless_mqtt" && !driverOnlyCh1 @@ -7515,7 +7539,7 @@ async function submitRequest(event) { { detail: phaseEvent.payload }, )); }; - if (state.provider === "openai") { + if (requestProvider === "openai") { const preview = await postControlPhase( "/api/requests/preview", requestBody, @@ -7578,6 +7602,13 @@ async function submitRequest(event) { } 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) { diff --git a/apps/web-console/index.html b/apps/web-console/index.html index 4014b07..bf34391 100755 --- a/apps/web-console/index.html +++ b/apps/web-console/index.html @@ -11,13 +11,15 @@ 511工程-智能座舱 + - + + @@ -73,6 +75,12 @@
+ + Enter 执行 · Shift + Enter 换行
diff --git a/apps/web-console/service-worker.js b/apps/web-console/service-worker.js index e3a0c14..1f29abb 100644 --- a/apps/web-console/service-worker.js +++ b/apps/web-console/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_VERSION = "cognitive-agent-pad-v210"; +const CACHE_VERSION = "cognitive-agent-pad-v216"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`; const APP_SHELL = [ @@ -6,11 +6,14 @@ const APP_SHELL = [ "/index.html", "/styles.css?v=dual-seat-16", "/network.js?v=android-pad-2", - "/app.js?v=dual-seat-103", + "/app.js?v=dual-seat-104", "/gesture-control.js?v=android-pad-9", "/pwa.js?v=android-pad-3", "/console-v2.css?v=sync-layout-41", + "/speech-input.css?v=sensevoice-4", "/console-v2.js?v=sync-layout-43", + "/speech-input.js?v=sensevoice-6", + "/speech-audio-worklet.js?v=sensevoice-4", "/self-extension.css?v=nfc-toy-33", "/self-extension.js?v=nfc-toy-33", "/assets/pets/self-extension-cat.png?v=3", diff --git a/apps/web-console/speech-audio-worklet.js b/apps/web-console/speech-audio-worklet.js new file mode 100644 index 0000000..b106709 --- /dev/null +++ b/apps/web-console/speech-audio-worklet.js @@ -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); diff --git a/apps/web-console/speech-input.css b/apps/web-console/speech-input.css new file mode 100644 index 0000000..7d72d54 --- /dev/null +++ b/apps/web-console/speech-input.css @@ -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; + } +} diff --git a/apps/web-console/speech-input.js b/apps/web-console/speech-input.js new file mode 100644 index 0000000..4557d66 --- /dev/null +++ b/apps/web-console/speech-input.js @@ -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); +})(); diff --git a/config/native_can_capabilities/driver_seat.yaml b/config/native_can_capabilities/driver_seat.yaml index 452189c..01052a7 100644 --- a/config/native_can_capabilities/driver_seat.yaml +++ b/config/native_can_capabilities/driver_seat.yaml @@ -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 diff --git a/contracts/schemas/function-config.schema.json b/contracts/schemas/function-config.schema.json index 1774de8..3da1347 100755 --- a/contracts/schemas/function-config.schema.json +++ b/contracts/schemas/function-config.schema.json @@ -14,7 +14,7 @@ "parameters": {"type": "object"}, "parse_source": { "type": "string", - "enum": ["local_rule", "cloud_model", "cloud_supplement", "direct_control"] + "enum": ["local_rule", "cloud_model", "cloud_supplement", "direct_control", "speech_semantic"] }, "motion": { "type": "object", diff --git a/docs/speech-benchmark.md b/docs/speech-benchmark.md new file mode 100644 index 0000000..d9c6117 --- /dev/null +++ b/docs/speech-benchmark.md @@ -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 +``` diff --git a/docs/speech-recognition.md b/docs/speech-recognition.md new file mode 100644 index 0000000..f82dc66 --- /dev/null +++ b/docs/speech-recognition.md @@ -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 模型。 diff --git a/pyproject.toml b/pyproject.toml index c3b2985..1798c86 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/cognitive_agent/application/capability_registry.py b/src/cognitive_agent/application/capability_registry.py index 3320f45..7128eb1 100755 --- a/src/cognitive_agent/application/capability_registry.py +++ b/src/cognitive_agent/application/capability_registry.py @@ -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"], } ) diff --git a/src/cognitive_agent/application/chinese_values.py b/src/cognitive_agent/application/chinese_values.py new file mode 100644 index 0000000..e16a2e7 --- /dev/null +++ b/src/cognitive_agent/application/chinese_values.py @@ -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"(? 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 diff --git a/src/cognitive_agent/application/native_can_intent.py b/src/cognitive_agent/application/native_can_intent.py index e097271..1d8ae72 100644 --- a/src/cognitive_agent/application/native_can_intent.py +++ b/src/cognitive_agent/application/native_can_intent.py @@ -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", @@ -595,8 +596,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 @@ -608,9 +621,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( @@ -850,24 +865,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 = ( "用户指定了单个车窗的绝对开度。" diff --git a/src/cognitive_agent/application/seat_service.py b/src/cognitive_agent/application/seat_service.py index 3605ef2..8466681 100755 --- a/src/cognitive_agent/application/seat_service.py +++ b/src/cognitive_agent/application/seat_service.py @@ -1445,6 +1445,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, @@ -1465,6 +1598,7 @@ class SeatDemoService: _allow_empty_control_result: bool = False, _on_commands_validated: Callable[[dict[str, Any]], None] | None = None, _direct_can_batch: tuple[list[str], dict[str, Any]] | 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 @@ -1490,6 +1624,7 @@ class SeatDemoService: _allow_empty_control_result, _on_commands_validated, _direct_can_batch, + _speech_understanding, ) if control_transport == WIRELESS_MQTT_TRANSPORT: # The head unit owns both physical CAN channels in wireless mode. @@ -1511,6 +1646,7 @@ class SeatDemoService: _allow_empty_control_result, _on_commands_validated, _direct_can_batch, + _speech_understanding, ) if hardware_access_bypass or self._driver_only_vehicle_execution_active( control_transport @@ -1532,6 +1668,7 @@ class SeatDemoService: _allow_empty_control_result, _on_commands_validated, _direct_can_batch, + _speech_understanding, ) authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id) with self.hardware_execution.reserve(): @@ -1552,6 +1689,7 @@ class SeatDemoService: _allow_empty_control_result, _on_commands_validated, _direct_can_batch, + _speech_understanding, ) def _process_once( @@ -1571,6 +1709,7 @@ class SeatDemoService: allow_empty_control_result: bool = False, on_commands_validated: Callable[[dict[str, Any]], None] | None = None, direct_can_batch: tuple[list[str], dict[str, Any]] | None = None, + speech_understanding: dict[str, Any] | None = None, ) -> dict[str, Any]: control_transport = self._validate_control_transport(control_transport) utterance = utterance.strip() @@ -1641,7 +1780,7 @@ class SeatDemoService: context["mode_profiles"] = self.mode_profiles.public_modes(vehicle_profile.id) matched_modes = ( [] - 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, @@ -1691,6 +1830,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() @@ -1733,6 +1882,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, diff --git a/src/cognitive_agent/infrastructure/speech/__init__.py b/src/cognitive_agent/infrastructure/speech/__init__.py new file mode 100644 index 0000000..e60a9c4 --- /dev/null +++ b/src/cognitive_agent/infrastructure/speech/__init__.py @@ -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", +] diff --git a/src/cognitive_agent/infrastructure/speech/assembler.py b/src/cognitive_agent/infrastructure/speech/assembler.py new file mode 100644 index 0000000..a8e5577 --- /dev/null +++ b/src/cognitive_agent/infrastructure/speech/assembler.py @@ -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}" diff --git a/src/cognitive_agent/infrastructure/speech/capability.py b/src/cognitive_agent/infrastructure/speech/capability.py new file mode 100644 index 0000000..5aea60a --- /dev/null +++ b/src/cognitive_agent/infrastructure/speech/capability.py @@ -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 diff --git a/src/cognitive_agent/infrastructure/speech/contracts.py b/src/cognitive_agent/infrastructure/speech/contracts.py new file mode 100644 index 0000000..6b3bda2 --- /dev/null +++ b/src/cognitive_agent/infrastructure/speech/contracts.py @@ -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 diff --git a/src/cognitive_agent/infrastructure/speech/recovery.py b/src/cognitive_agent/infrastructure/speech/recovery.py new file mode 100644 index 0000000..e9ceb5b --- /dev/null +++ b/src/cognitive_agent/infrastructure/speech/recovery.py @@ -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 diff --git a/src/cognitive_agent/infrastructure/speech/semantic.py b/src/cognitive_agent/infrastructure/speech/semantic.py new file mode 100644 index 0000000..65e9ceb --- /dev/null +++ b/src/cognitive_agent/infrastructure/speech/semantic.py @@ -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, + ) diff --git a/src/cognitive_agent/infrastructure/speech/sensevoice.py b/src/cognitive_agent/infrastructure/speech/sensevoice.py new file mode 100644 index 0000000..8457aef --- /dev/null +++ b/src/cognitive_agent/infrastructure/speech/sensevoice.py @@ -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 diff --git a/src/cognitive_agent/infrastructure/speech/tracing.py b/src/cognitive_agent/infrastructure/speech/tracing.py new file mode 100644 index 0000000..6cce299 --- /dev/null +++ b/src/cognitive_agent/infrastructure/speech/tracing.py @@ -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 [] diff --git a/src/cognitive_agent/infrastructure/speech/understanding.py b/src/cognitive_agent/infrastructure/speech/understanding.py new file mode 100644 index 0000000..122872d --- /dev/null +++ b/src/cognitive_agent/infrastructure/speech/understanding.py @@ -0,0 +1,1625 @@ +"""Capability-driven Chinese understanding for short vehicle-control speech.""" + +from __future__ import annotations + +import re +import time +from collections.abc import Iterable +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any + +from cognitive_agent.application.chinese_values import parse_percentage_value +from cognitive_agent.application.native_can_intent import merge_native_can_intent +from cognitive_agent.infrastructure.speech.capability import CapabilityResolver +from cognitive_agent.infrastructure.speech.contracts import ( + CancelledSpeechFragment, + ResolutionStatus, + SpeechCommandIntent, + TranscriptStatus, + UnderstandingTranscript, +) +from cognitive_agent.infrastructure.speech.vocabulary import ( + ACTION_ALIASES, + CANCELLATION_MARKERS, + CORRECTION_MARKERS, + DEVICE_TERMS, + NEGATION_PREFIXES, + POSITIVE_CONNECTORS, + SCOPE_ALIASES, + DeviceTerm, + action_terms_in, + color_in, + device_terms_in, + scope_in, +) + +_CLAUSE_SPLIT = re.compile(r"[\s,,;;。!?!?…]+") +_POSITIVE_SPLIT = re.compile( + rf"[\s,,;;]*({'|'.join(map(re.escape, POSITIVE_CONNECTORS))})[\s,,;;]*" +) +_COORDINATION_SPLIT = re.compile(r"\s*(?:以及|和|与|及|、)\s*") +_CORRECTION_PATTERN = re.compile( + rf"[\s,,;;]*({'|'.join(map(re.escape, sorted(CORRECTION_MARKERS, key=len, reverse=True)))}|不[,,]+是)[\s,,;;]*" +) +_CANCELLATION_PATTERN = re.compile( + rf"[\s,,;;]*({'|'.join(map(re.escape, CANCELLATION_MARKERS))})[\s,,;;]*" +) +_VOCAL_FILLER = re.compile(r"(?那个|这个)(?:[\s,,]*(?P=word)){1,}") +_LEADING_FILLER = re.compile( + r"^(?:(?:呃|嗯|额|啊|呐)+[\s,,]*)?(?:那个|这个|就是)[\s,,]*" + r"(?=(?:把|将|请|打开|开启|关闭|关掉|调整|调节|设置))" +) +_POLITE_PREFIX = re.compile(r"^(?:(?:请|麻烦|拜托)(?:帮我|给我)?|你帮我|帮我|给我)[\s,,]*") +_POLITE_SUFFIX = re.compile(r"[\s,,]*(?:可以吗|行吗|好吗|好不好|谢谢)[。!?!?]*$") +_NUMBER = re.compile(r"(? None: + repository = repository_root or Path(__file__).resolve().parents[4] + if not (repository / "domains").is_dir(): + repository = Path(__file__).resolve().parents[4] + self.resolver = CapabilityResolver(repository) + + def process( + self, + raw_text: str, + *, + timestamps: Iterable[object] | None = None, + previous_context: UnderstandingTranscript | None = None, + ) -> UnderstandingTranscript: + del timestamps, previous_context + started = time.perf_counter() + original = self._tidy(raw_text) + prepared, removed_fillers = self._prepare(original) + active: list[SpeechCommandIntent] = [] + cancelled: list[CancelledSpeechFragment] = [] + reasons: list[str] = [] + repair_resolved = False + resolution_ms = 0.0 + clauses: list[str] = [] + shared_target: SpeechCommandIntent | None = None + + if not prepared: + reasons.append("filler_only") + else: + blocks = [part for part in _POSITIVE_SPLIT.split(prepared) if part] + blocks = [part for part in blocks if part not in POSITIVE_CONNECTORS] + for raw_block in blocks: + block = raw_block + if ( + shared_target is not None + and not device_terms_in(block) + and not _CORRECTION_PATTERN.search(block) + and not _CANCELLATION_PATTERN.search(block) + ): + block = self._inherit_shared_target(block, shared_target) + clauses.append(block) + ( + block_active, + block_cancelled, + block_reasons, + repaired, + block_resolution_ms, + ) = self._process_block(block) + active.extend(block_active) + cancelled.extend(block_cancelled) + reasons.extend(block_reasons) + repair_resolved = repair_resolved or repaired + resolution_ms += block_resolution_ms + if block_active: + shared_target = block_active[-1] + + active, obsolete_issues, atomic_issues, atomic_resolution_ms = ( + self._coalesce_atomic_commands(active) + ) + if obsolete_issues: + reasons = [reason for reason in reasons if reason not in obsolete_issues] + reasons.extend(atomic_issues) + resolution_ms += atomic_resolution_ms + reasons.extend(issue for command in active for issue in command.validation_issues) + if len(_CORRECTION_PATTERN.findall(prepared)) > 2: + reasons.append("correction_chain_too_long") + clean_text = self._render(active) + if not active: + if self._contains_negation(prepared): + clean_text = self._sentence(prepared) + elif not cancelled and prepared and not self._looks_like_control(prepared): + clean_text = "" + + unique_reasons = tuple(dict.fromkeys(reason for reason in reasons if reason)) + semantic_status = self._semantic_status(active, unique_reasons) + benign = { + "non_control_speech", + "filler_only", + "cancelled_by_user", + "negated_command", + } + uncertain = semantic_status in { + "recognized_unsupported", + "ambiguous", + "incomplete", + } or any(reason not in benign for reason in unique_reasons) + domains = tuple(dict.fromkeys(command.domain for command in active)) + processing_ms = (time.perf_counter() - started) * 1000 + semantic_trace = self._semantic_trace( + original, + prepared, + clauses, + active, + clean_text, + semantic_status, + ) + return UnderstandingTranscript( + raw_text=original, + clean_text=clean_text, + commands=tuple(active), + cancelled_fragments=tuple(cancelled), + semantic_status=semantic_status, + uncertain=uncertain, + reasons=unique_reasons, + removed_fillers=tuple(dict.fromkeys(removed_fillers)), + repair_resolved=repair_resolved, + intent_domains=domains, + changed=clean_text.rstrip("。") != original.rstrip("。"), + processing_ms=processing_ms, + command_resolution_ms=resolution_ms, + semantic_trace=semantic_trace, + ) + + def _process_block( + self, block: str + ) -> tuple[ + list[SpeechCommandIntent], + list[CancelledSpeechFragment], + list[str], + bool, + float, + ]: + block = self._tidy(block) + active: list[SpeechCommandIntent] = [] + cancelled: list[CancelledSpeechFragment] = [] + reasons: list[str] = [] + resolution_ms = 0.0 + cancellation = _CANCELLATION_PATTERN.search(block) + if cancellation: + prefix = block[: cancellation.start()].strip(",,;;。!?!? ") + if prefix: + analysis = self._analyse_fragment(prefix) + resolution_ms += analysis.resolution_ms + cancelled.append( + CancelledSpeechFragment( + prefix, + "cancelled_by_user", + cancellation.group(1), + self._cancel_commands(analysis.commands), + ) + ) + reasons.append("cancelled_by_user") + suffix = block[cancellation.end() :].strip(",,;;。!?!? ") + if suffix: + follow = self._analyse_fragment(suffix) + resolution_ms += follow.resolution_ms + active.extend(follow.commands) + reasons.extend(follow.uncertain_reasons) + return active, cancelled, reasons, True, resolution_ms + + correction = _CORRECTION_PATTERN.search(block) + if correction: + prefix = block[: correction.start()].strip(",,;;。!?!? ") + suffix = block[correction.end() :].strip(",,;;。!?!? ") + analyses = [self._analyse_fragment(item) for item in self._plain_fragments(prefix)] + resolution_ms += sum(item.resolution_ms for item in analyses) + if analyses: + active.extend(command for item in analyses[:-1] for command in item.commands) + old = analyses[-1] + cancelled.append( + CancelledSpeechFragment( + old.text, + "self_correction", + correction.group(1), + self._cancel_commands(old.commands), + ) + ) + else: + old = _FragmentAnalysis(prefix, ()) + if not suffix: + reasons.append("incomplete_correction") + return active, cancelled, reasons, False, resolution_ms + inherited = self._inherit_context(suffix, old) + if _CORRECTION_PATTERN.search(inherited) or _CANCELLATION_PATTERN.search(inherited): + ( + new_active, + new_cancelled, + new_reasons, + repaired, + nested_resolution_ms, + ) = self._process_block(inherited) + active.extend(new_active) + cancelled.extend(new_cancelled) + reasons.extend(new_reasons) + return active, cancelled, reasons, repaired, resolution_ms + nested_resolution_ms + new = self._analyse_fragment(inherited) + resolution_ms += new.resolution_ms + active.extend(new.commands) + reasons.extend(new.uncertain_reasons) + if not new.commands: + reasons.append("correction_not_resolved") + return active, cancelled, reasons, bool(new.commands), resolution_ms + + analyses = [self._analyse_fragment(item) for item in self._plain_fragments(block)] + resolution_ms += sum(item.resolution_ms for item in analyses) + for index, analysis in enumerate(analyses): + following = analyses[index + 1] if index + 1 < len(analyses) else None + if self._is_abandoned_false_start(analysis, following): + cancelled.append( + CancelledSpeechFragment( + analysis.text, + "incomplete_false_start", + commands=self._cancel_commands(analysis.commands), + ) + ) + continue + if self._is_target_refinement(analysis, following): + cancelled.append( + CancelledSpeechFragment( + analysis.text, + "target_refinement", + commands=self._cancel_commands(analysis.commands), + ) + ) + continue + if self._contains_negation(analysis.text): + cancelled.append( + CancelledSpeechFragment( + analysis.text, + "negated_command", + commands=self._cancel_commands(analysis.commands, negated=True), + ) + ) + reasons.append("negated_command") + continue + active.extend(analysis.commands) + reasons.extend(analysis.uncertain_reasons) + if analysis.non_control: + reasons.append("non_control_speech") + return active, cancelled, reasons, False, resolution_ms + + @staticmethod + def _plain_fragments(text: str) -> list[str]: + return [part for part in _CLAUSE_SPLIT.split(text) if part] + + def _analyse_fragment( + self, text: str, *, allow_coordination: bool = True + ) -> _FragmentAnalysis: + text = self._tidy(text) + if allow_coordination: + coordinated_analysis = self._analyse_coordinated_seat_properties(text) + if coordinated_analysis is not None: + return coordinated_analysis + coordinated_analysis = self._analyse_coordinated_controls(text) + if coordinated_analysis is not None: + return coordinated_analysis + application_analysis = self._analyse_application_intent(text) + if application_analysis.commands and self._application_slots_preserved( + text, application_analysis + ): + return application_analysis + devices = device_terms_in(text) + actions = action_terms_in(text) + mode = self.resolver.match_mode(text) + if mode is not None and (devices or actions): + term = ( + self._most_specific_device(devices) + if devices + else next(item for item in DEVICE_TERMS if item.canonical == "座椅") + ) + commands, issues, resolution_ms = self._commands_for( + text, + term, + self._select_action(actions) if actions else "set", + self._contains_negation(text), + ) + return _FragmentAnalysis( + text, + tuple(commands), + tuple(issues), + incomplete=not commands, + resolution_ms=resolution_ms, + ) + if not devices or not actions: + looks_control = bool(devices or actions or _CONTROL_SCAFFOLD.search(text)) + return _FragmentAnalysis( + text, + (), + ("incomplete_control_fragment",) if looks_control else (), + non_control=not looks_control, + incomplete=looks_control, + ) + distinct = list(dict.fromkeys(item[2].canonical for item in devices)) + if len(distinct) > 1 and not self._same_target_family(devices): + return _FragmentAnalysis( + text, (), ("multiple_targets_without_connector",), incomplete=True + ) + term = self._most_specific_device(devices) + action = self._select_action(actions) + commands, issues, resolution_ms = self._commands_for( + text, term, action, self._contains_negation(text) + ) + return _FragmentAnalysis( + text, + tuple(commands), + tuple(issues), + incomplete=not commands, + resolution_ms=resolution_ms, + ) + + def _analyse_coordinated_seat_properties( + self, text: str + ) -> _FragmentAnalysis | None: + """Expand coordinated properties while retaining one shared seat target. + + Chinese commonly omits the second ``座椅`` in expressions such as + ``座椅通风和加热``. This is a grammar relation between two properties, + not a new capability table: each expanded term still resolves through + the authoritative catalog. + """ + + match = re.search( + r"(?:座椅)?(?P通风|加热)(?:和|与|以及|及|、)" + r"(?P打开|开启|启动|关闭|关掉|停止)?" + r"(?:座椅)?(?P通风|加热)", + text, + ) + if match is None or match.group("first") == match.group("second"): + return None + actions = action_terms_in(text) + prefix_actions = [item for item in actions if item[0] < match.start()] + if not prefix_actions: + return None + shared_action = self._select_action(prefix_actions) + semantic_keys = { + "通风": "seat.ventilation", + "加热": "seat.heating", + } + terms_by_key = {item.semantic_key: item for item in DEVICE_TERMS} + commands: list[SpeechCommandIntent] = [] + issues: list[str] = [] + resolution_ms = 0.0 + features = (match.group("first"), match.group("second")) + for index, feature in enumerate(features): + term = terms_by_key[semantic_keys[feature]] + action = shared_action + if index == 1 and match.group("second_action"): + local_actions = action_terms_in(match.group("second_action")) + if local_actions: + action = self._select_action(local_actions) + resolved, local_issues, local_ms = self._commands_for( + text, term, action, self._contains_negation(text) + ) + commands.extend(resolved) + issues.extend(local_issues) + resolution_ms += local_ms + return _FragmentAnalysis( + text, + tuple(commands), + tuple(dict.fromkeys(issues)), + incomplete=not commands, + resolution_ms=resolution_ms, + ) + + def _analyse_coordinated_controls( + self, text: str + ) -> _FragmentAnalysis | None: + """Resolve explicit target lists without leaking actions or scopes. + + Each coordinated part is parsed independently. A missing ON/OFF verb + can inherit the shared verb, while target qualifiers remain local to + their own part. Other operations are left to the conservative parser + because propagating a value/unit across targets can be unsafe. + """ + + parts = [part for part in _COORDINATION_SPLIT.split(text) if part] + if len(parts) < 2: + return None + devices_by_part = [device_terms_in(part) for part in parts] + explicit_device_parts = [index for index, items in enumerate(devices_by_part) if items] + if not explicit_device_parts: + return None + + actions_by_part = [action_terms_in(part) for part in parts] + explicit_actions = [ + item[2] + for actions in actions_by_part + for item in actions + if item[2] in {"on", "off"} + ] + if not explicit_actions: + return None + shared_action = explicit_actions[0] + + common_term: DeviceTerm | None = None + if len(explicit_device_parts) == 1: + common_term = self._most_specific_device( + devices_by_part[explicit_device_parts[0]] + ) + if any( + not devices + and not any(alias in part for aliases in SCOPE_ALIASES.values() for alias in aliases) + for part, devices in zip(parts, devices_by_part, strict=True) + ): + return None + elif not all(devices_by_part): + return None + + expanded: list[str] = [] + for part, devices, actions in zip( + parts, devices_by_part, actions_by_part, strict=True + ): + local_action = next( + (item[2] for item in actions if item[2] in {"on", "off"}), + shared_action, + ) + action_text = "打开" if local_action == "on" else "关闭" + candidate = part + if not devices and common_term is not None: + candidate = f"{candidate}{common_term.canonical}" + if not actions: + candidate = f"{action_text}{candidate}" + expanded.append(candidate) + + analyses = [ + self._analyse_fragment(part, allow_coordination=False) for part in expanded + ] + if any(not analysis.commands for analysis in analyses): + return None + return _FragmentAnalysis( + text, + tuple(command for analysis in analyses for command in analysis.commands), + tuple( + dict.fromkeys( + issue + for analysis in analyses + for issue in analysis.uncertain_reasons + ) + ), + incomplete=any(analysis.incomplete for analysis in analyses), + resolution_ms=sum(analysis.resolution_ms for analysis in analyses), + ) + + def _application_slots_preserved( + self, text: str, analysis: _FragmentAnalysis + ) -> bool: + """Reject a legacy parse that silently downgraded an explicit value.""" + + expected_pct = self._percentage_value(text) + if expected_pct is None: + return True + parameters = [command.parameters for command in analysis.commands] + if any( + value == expected_pct + for item in parameters + for key, value in item.items() + if key.endswith("_pct") + ): + return True + # The roof protocol has a native half-open motion exactly equivalent + # to 50%; retain that authoritative representation. + return expected_pct == 50 and any( + item.get("motion") == "half" for item in parameters + ) + + def _coalesce_atomic_commands( + self, commands: list[SpeechCommandIntent] + ) -> tuple[list[SpeechCommandIntent], set[str], list[str], float]: + """Combine adjacent slots when one catalog capability requires them atomically.""" + + output: list[SpeechCommandIntent] = [] + obsolete_issues: set[str] = set() + issues: list[str] = [] + resolution_ms = 0.0 + index = 0 + while index < len(commands): + current = commands[index] + following = commands[index + 1] if index + 1 < len(commands) else None + if ( + following is not None + and current.property_name == following.property_name == "ambient.color" + and current.target_scope == following.target_scope + and "color_hex" in current.parameters + and "brightness_pct" in following.parameters + ): + parameters = { + "color_hex": current.parameters["color_hex"], + "brightness_pct": following.parameters["brightness_pct"], + } + started = time.perf_counter() + resolution = self.resolver.resolve( + domain="ambient_light", + semantic_property="ambient.color", + target_scope=current.target_scope, + target_device=None, + operation="set", + parameters=parameters, + source_text=f"{current.source_text},{following.source_text}", + preferred_capability="ambient_light.set_illumination", + ) + resolution_ms += (time.perf_counter() - started) * 1000 + obsolete_issues.update(current.validation_issues) + obsolete_issues.update(following.validation_issues) + merged_issues = tuple(resolution.issues) + output.append( + replace( + current, + capability=( + resolution.definition.name + if resolution.definition is not None + else None + ), + parameters=parameters, + source_text=f"{current.source_text},{following.source_text}", + resolution_status=resolution.status, + validation_issues=merged_issues, + confidence=0.96 if resolution.status == "executable" else 0.55, + executor_binding=resolution.executor_binding, + executor_payload=resolution.executor_payload, + intent_ir_action=resolution.intent_ir_action, + ) + ) + issues.extend(merged_issues) + index += 2 + continue + output.append(current) + index += 1 + return output, obsolete_issues, issues, resolution_ms + + def _analyse_application_intent(self, text: str) -> _FragmentAnalysis: + """Reuse deterministic actions already used by one-sentence orchestration.""" + + parsed = merge_native_can_intent({}, text) + actions = list(parsed.get("native_can_actions", ())) + if not actions: + return _FragmentAnalysis(text, ()) + + commands: list[SpeechCommandIntent] = [] + issues: list[str] = [] + resolution_ms = 0.0 + for action in actions: + started = time.perf_counter() + resolution = self.resolver.resolve_action(action, source_text=text) + resolution_ms += (time.perf_counter() - started) * 1000 + definition = resolution.definition + parameters = dict(action.get("parameters", {})) + scope = str(action.get("target", action.get("target_scope", "vehicle"))) + explicit_scope, spoken_scope = self._scope_qualifier(text, scope) + property_name = self.resolver.semantic_property_for( + str(action.get("capability", "")), parameters + ) + operation = self._semantic_operation(parameters, str(action.get("operation", "set"))) + implicit_parameters = self._implicit_application_parameters(text, parameters) + value, unit = self._semantic_value(definition, parameters, implicit_parameters) + command_issues = tuple(resolution.issues) + command_qualifiers: dict[str, Any] = { + "target_explicit": explicit_scope, + "spoken_scope": spoken_scope, + "parse_source": "application_intent", + "implicit_parameters": list(implicit_parameters), + } + if "level" in parameters and "level" not in implicit_parameters: + command_qualifiers["display_level"] = self._display_level(text) + commands.append( + SpeechCommandIntent( + domain=str(action.get("domain", "")), + capability=definition.name if definition is not None else None, + target_scope=scope, + target_device=( + definition.target_devices[0] + if definition is not None and definition.target_devices + else str(action.get("domain", "device")) + ), + property_name=property_name, + operation=operation, + parameters=parameters, + value=value, + unit=unit, + qualifiers=command_qualifiers, + negated=self._contains_negation(text), + confidence=(0.98 if resolution.status == "executable" else 0.55), + source_text=text, + resolution_status=resolution.status, + validation_issues=command_issues, + executor_binding=resolution.executor_binding, + executor_payload=resolution.executor_payload, + intent_ir_action=resolution.intent_ir_action, + ) + ) + issues.extend(command_issues) + return _FragmentAnalysis( + text, + tuple(commands), + tuple(dict.fromkeys(issues)), + resolution_ms=resolution_ms, + ) + + @staticmethod + def _semantic_operation(parameters: dict[str, Any], fallback: str) -> str: + state = parameters.get("state") + if state in {"on", "off"}: + return str(state) + motion = parameters.get("motion") + if motion in {"open", "close", "stop"}: + return str(motion) + direction = parameters.get("direction") + if direction in {"forward", "backward", "up", "down", "stop"}: + return str(direction) + return fallback + + @staticmethod + def _semantic_value( + definition: Any, + parameters: dict[str, Any], + implicit_parameters: tuple[str, ...] = (), + ) -> tuple[Any, str | None]: + preferred = ( + "temperature_c", + "position_pct", + "angle_deg", + "angle_delta_deg", + "level", + "brightness_pct", + "brightness_level", + "color_hex", + "color_index", + "state", + "motion", + "direction", + "mode", + "slot", + ) + name = next( + (item for item in preferred if item in parameters and item not in implicit_parameters), + None, + ) + if name is None: + name = next((item for item in preferred if item in parameters), None) + if name is None: + return (next(iter(parameters.values()), None), None) + units = { + str(item.get("name")): item.get("unit") + for item in (definition.parameters if definition is not None else ()) + } + return parameters[name], str(units[name]) if units.get(name) else None + + @staticmethod + def _implicit_application_parameters(text: str, parameters: dict[str, Any]) -> tuple[str, ...]: + implicit: list[str] = [] + if "duration_ms" in parameters and not re.search(r"\d+\s*毫秒", text): + implicit.append("duration_ms") + if "level" in parameters and not re.search( + r"(?:\d+|[一二两三四五])\s*(?:档|级)|最低|低档|中档|最高|高档|最弱|最强", + text, + ): + implicit.append("level") + return tuple(implicit) + + @classmethod + def _display_level(cls, text: str) -> str: + if re.search(r"最高|高档|最强", text): + return "高档" + if re.search(r"最低|低档|最弱", text): + return "低档" + if re.search(r"中档|中等|适中", text): + return "中档" + value = cls._numeric_value(text) + return f"{cls._format_number(value)}档" if value is not None else "" + + def _commands_for( + self, text: str, term: DeviceTerm, action: str, negated: bool + ) -> tuple[list[SpeechCommandIntent], list[str], float]: + default_scope = ( + "passenger" + if term.domain == "seat" + else "front_left" + if term.domain == "window" + else "all" + if term.semantic_key == "reading_light.power" + else "vehicle" + ) + scope = scope_in(text, default=default_scope, domain=term.domain) + explicit_scope, spoken_scope = self._scope_qualifier(text, scope) + qualifiers: dict[str, Any] = { + "target_explicit": explicit_scope, + "spoken_scope": spoken_scope, + } + spoken_directions = [ + action + for _, _, action, _ in action_terms_in(text) + if action in {"forward", "backward", "up", "down"} + ] + if spoken_directions: + qualifiers["spoken_direction"] = spoken_directions[-1] + value = self._numeric_value(text) + percent_value = self._percentage_value(text) + has_degree = "度" in text + issues: list[str] = [] + commands: list[SpeechCommandIntent] = [] + resolution_ms = 0.0 + + def make( + semantic_property: str, + operation: str, + parameters: dict[str, Any], + *, + semantic_value: Any = None, + unit: str | None = None, + confidence: float = 0.94, + forced_status: ResolutionStatus | None = None, + extra_issues: tuple[str, ...] = (), + display_value: str | None = None, + ) -> SpeechCommandIntent: + nonlocal resolution_ms + resolution_started = time.perf_counter() + resolution = self.resolver.resolve( + domain=term.domain, + semantic_property=semantic_property, + target_scope=scope, + target_device=None, + operation=operation, + parameters=parameters, + source_text=text, + ) + resolution_ms += (time.perf_counter() - resolution_started) * 1000 + command_issues = tuple(dict.fromkeys((*resolution.issues, *extra_issues))) + resolved_status = forced_status or resolution.status + if extra_issues and forced_status is None and resolved_status == "executable": + resolved_status = "incomplete" + definition = resolution.definition + command_qualifiers = dict(qualifiers) + if display_value: + command_qualifiers["display_value"] = display_value + return SpeechCommandIntent( + domain=term.domain, + capability=definition.name if definition is not None else None, + target_scope=scope, + target_device=( + definition.target_devices[0] + if definition is not None and definition.target_devices + else term.target_device + ), + property_name=semantic_property, + operation=operation, + parameters=dict(parameters), + value=semantic_value, + unit=unit, + qualifiers=command_qualifiers, + negated=negated, + confidence=confidence + if resolved_status == "executable" and not command_issues + else min(confidence, 0.55), + source_text=text, + resolution_status=resolved_status, + validation_issues=command_issues, + executor_binding=resolution.executor_binding + if resolved_status == "executable" + else None, + executor_payload=resolution.executor_payload + if resolved_status == "executable" + else None, + intent_ir_action=resolution.intent_ir_action + if resolved_status == "executable" + else None, + ) + + mode_profile = self.resolver.match_mode(text) + if mode_profile is not None: + mode_started = time.perf_counter() + mode_resolution = self.resolver.resolve_mode( + text, + target_scope=scope, + target_explicit=explicit_scope, + ) + resolution_ms += (time.perf_counter() - mode_started) * 1000 + mode_issues = tuple(mode_resolution.issues) + command_scope = scope if explicit_scope else mode_profile.operations[0].target_scope + command = SpeechCommandIntent( + domain="seat", + capability=None, + target_scope=command_scope, + target_device="seat", + property_name="seat.mode", + operation="apply", + parameters={"mode_profile_id": mode_profile.id}, + value=mode_profile.name, + unit=None, + qualifiers=dict(qualifiers), + negated=negated, + confidence=0.96 if mode_resolution.status == "executable" else 0.55, + source_text=text, + resolution_status=mode_resolution.status, + validation_issues=mode_issues, + executor_binding="ModeProfileRegistry/ModelPlanAdapter" + if mode_resolution.status == "executable" + else None, + executor_payload=( + { + "mode_profile_id": mode_profile.id, + "operations": list(mode_resolution.executor_payloads), + } + if mode_resolution.status == "executable" + else None + ), + mode_profile_id=mode_profile.id, + ) + commands.append(command) + issues.extend(mode_issues) + return commands, issues, resolution_ms + + if term.domain == "climate": + if term.semantic_key == "hvac.fan_level": + local = () if value is not None else ("fan_level_incomplete",) + commands.append( + make( + "hvac.fan_level", + "set", + {"level": int(value)} if value is not None else {}, + semantic_value=value, + unit="level", + forced_status="incomplete" if local else None, + extra_issues=local, + ) + ) + elif term.semantic_key == "hvac.temperature": + local = () if value is not None else ("temperature_incomplete",) + commands.append( + make( + "hvac.temperature", + "set", + {"temperature_c": value} if value is not None else {}, + semantic_value=value, + unit="celsius", + forced_status="incomplete" if local else None, + extra_issues=local, + ) + ) + else: + if action in {"on", "off"}: + commands.append( + make("hvac.power", action, {"state": action}, semantic_value=action) + ) + if value is not None and (action in {"set", "adjust", "on"} or "温度" in text): + commands.append( + make( + "hvac.temperature", + "set", + {"temperature_c": value}, + semantic_value=value, + unit="celsius", + ) + ) + if not commands: + issues.append("climate_action_or_value_incomplete") + issues.extend(issue for command in commands for issue in command.validation_issues) + return commands, issues, resolution_ms + + if term.semantic_key in {"seat.ventilation", "seat.heating"}: + local: tuple[str, ...] = () + parameters: dict[str, Any] = {} + if action in {"on", "off"} or (action in {"set", "adjust"} and value is not None): + parameters["state"] = action if action in {"on", "off"} else "on" + if value is not None: + parameters["level"] = int(value) + else: + local = ("seat_thermal_action_incomplete",) + commands.append( + make( + term.semantic_key, + action, + parameters, + semantic_value=parameters.get("state"), + unit="level" if "level" in parameters else None, + forced_status="incomplete" if local else None, + extra_issues=local, + ) + ) + issues.extend(issue for command in commands for issue in command.validation_issues) + return commands, issues, resolution_ms + + if term.domain == "seat": + parameters: dict[str, Any] = {} + local: list[str] = [] + semantic_unit: str | None = None + if value is not None or percent_value is not None: + if term.semantic_key == "seat.backrest_recline": + if action in {"forward", "backward"}: + if value is not None: + parameters["angle_delta_deg"] = abs(value) + semantic_unit = "degree" + elif percent_value is not None: + parameters["position_pct"] = percent_value + semantic_unit = "percent" + elif percent_value is not None: + parameters["position_pct"] = percent_value + semantic_unit = "percent" + elif has_degree: + parameters["angle_deg"] = value + semantic_unit = "degree" + else: + local.append("seat_value_unit_ambiguous") + elif percent_value is not None: + parameters["position_pct"] = percent_value + semantic_unit = "percent" + else: + local.append("seat_value_unit_ambiguous") + if not parameters: + local.append("seat_parameter_incomplete") + commands.append( + make( + term.semantic_key, + action, + parameters, + semantic_value=next(iter(parameters.values())) if parameters else value, + unit=semantic_unit, + confidence=0.9, + forced_status="incomplete" if local else None, + extra_issues=tuple(local), + ) + ) + issues.extend(issue for command in commands for issue in command.validation_issues) + return commands, issues, resolution_ms + + if term.domain == "window": + if percent_value is not None: + commands.append( + make( + "window.position", + "set", + {"position_pct": percent_value}, + semantic_value=percent_value, + unit="percent", + ) + ) + elif action in {"on", "forward", "up"}: + commands.append( + make("window.motion", "open", {"motion": "open"}, semantic_value="open") + ) + elif action in {"off", "backward", "down"}: + commands.append( + make("window.motion", "close", {"motion": "close"}, semantic_value="close") + ) + else: + issues.append("window_action_incomplete") + if value is not None and percent_value is None: + issues.append("window_numeric_value_without_percent") + commands = [ + replace( + command, + resolution_status="ambiguous", + validation_issues=tuple( + dict.fromkeys( + (*command.validation_issues, "window_numeric_value_without_percent") + ) + ), + executor_binding=None, + executor_payload=None, + intent_ir_action=None, + ) + for command in commands + ] + issues.extend(issue for command in commands for issue in command.validation_issues) + return commands, issues, resolution_ms + + if term.semantic_key == "roof.shade_motion": + half = bool(re.search(r"一半|半开|开一半", text)) + if half: + commands.append( + make( + "roof.shade_motion", + "set", + {"motion": "half"}, + semantic_value=50, + unit="percent", + ) + ) + elif percent_value is not None: + commands.append( + make( + "roof.shade_position", + "set", + {"position_pct": percent_value}, + semantic_value=percent_value, + unit="percent", + ) + ) + else: + motion = "open" if action == "on" else "close" if action == "off" else "" + if motion: + commands.append( + make("roof.shade_motion", motion, {"motion": motion}, semantic_value=motion) + ) + else: + issues.append("roof_shade_action_incomplete") + issues.extend(issue for command in commands for issue in command.validation_issues) + return commands, issues, resolution_ms + + if term.semantic_key == "roof.transmittance": + local = () if percent_value is not None else ("roof_transmittance_incomplete",) + commands.append( + make( + "roof.transmittance", + "set", + {"transmittance_pct": percent_value} if percent_value is not None else {}, + semantic_value=percent_value, + unit="percent", + forced_status="incomplete" if local else None, + extra_issues=local, + ) + ) + issues.extend(issue for command in commands for issue in command.validation_issues) + return commands, issues, resolution_ms + + if term.semantic_key == "ambient.power": + spoken_color = color_in(text) + if action in {"on", "off"}: + commands.append( + make("ambient.power", action, {"state": action}, semantic_value=action) + ) + if spoken_color is not None: + color_hex, color_alias = spoken_color + color_parameters: dict[str, Any] = {"color_hex": color_hex} + if percent_value is not None and "亮度" in text: + color_parameters["brightness_pct"] = percent_value + commands.append( + make( + "ambient.color", + "set", + color_parameters, + semantic_value=color_hex, + unit="rgb", + display_value=color_alias, + ) + ) + elif percent_value is not None and "亮度" in text: + commands.append( + make( + "ambient.color", + "set", + {"brightness_pct": percent_value}, + semantic_value=percent_value, + unit="percent", + display_value="brightness_only", + ) + ) + elif value is not None and "亮度" in text: + commands.append( + make( + "ambient.brightness", + "set", + {"brightness_level": int(value)}, + semantic_value=int(value), + unit="level", + ) + ) + if not commands: + issues.append("ambient_control_incomplete") + issues.extend(issue for command in commands for issue in command.validation_issues) + return commands, issues, resolution_ms + + if term.semantic_key in {"reading_light.power", "indoor_light.power", "front_light.power"}: + if action in {"on", "off"}: + commands.append( + make(term.semantic_key, action, {"state": action}, semantic_value=action) + ) + else: + issues.append("light_action_incomplete") + issues.extend(issue for command in commands for issue in command.validation_issues) + return commands, issues, resolution_ms + + issues.append("unsupported_control_expression") + return commands, issues, resolution_ms + + def _inherit_context(self, correction: str, old: _FragmentAnalysis) -> str: + correction = re.sub(r"^是[\s,,]*", "", correction) + if device_terms_in(correction): + return correction + if not old.commands: + devices = device_terms_in(old.text) + return ( + f"{self._most_specific_device(devices).canonical}{correction}" + if devices + else correction + ) + previous = old.commands[-1] + target = self._display_target(previous) + if action_terms_in(correction): + return f"{target}{correction}" + if self._numeric_value(correction) is not None: + return f"{target}调整到{correction}" + return f"{target}{correction}" + + def _inherit_shared_target(self, fragment: str, command: SpeechCommandIntent) -> str: + target = self._display_target(command) + if ( + command.domain == "seat" + and not command.qualifiers.get("target_explicit") + and command.target_scope in {"driver", "front_left", "passenger", "front_right"} + ): + scope = "主驾" if command.target_scope in {"driver", "front_left"} else "副驾" + target = f"{scope}{target}" + return f"{target}{fragment}" + + @staticmethod + def _cancel_commands( + commands: Iterable[SpeechCommandIntent], *, negated: bool = False + ) -> tuple[SpeechCommandIntent, ...]: + return tuple(replace(command, status="cancelled", negated=negated) for command in commands) + + @staticmethod + def _is_abandoned_false_start( + analysis: _FragmentAnalysis, following: _FragmentAnalysis | None + ) -> bool: + if following is None or not following.commands: + return False + for _, end, _, alias in action_terms_in(analysis.text): + tail = analysis.text[end:].strip() + if tail and len(tail) <= 2 and all(char == alias[0] for char in tail): + return True + return analysis.incomplete and not analysis.commands + + @staticmethod + def _is_target_refinement( + analysis: _FragmentAnalysis, following: _FragmentAnalysis | None + ) -> bool: + if following is None or not following.commands or not analysis.commands: + return False + old, new = analysis.commands[-1], following.commands[0] + return ( + old.domain == new.domain == "seat" + and old.property_name == "seat.longitudinal_position" + and new.property_name != old.property_name + and "度" not in analysis.text + and "%" not in analysis.text + ) + + @staticmethod + def _same_target_family(devices: list[tuple[int, int, DeviceTerm, str]]) -> bool: + return {item[2].domain for item in devices} == {"seat"} + + @staticmethod + def _select_action(actions: list[tuple[int, int, str, str]]) -> str: + if not actions: + return "set" + if actions[-1][2] == "adjust": + for _, _, action, _ in reversed(actions[:-1]): + if action in {"forward", "backward", "up", "down"}: + return action + return actions[-1][2] + + @staticmethod + def _most_specific_device(devices: list[tuple[int, int, DeviceTerm, str]]) -> DeviceTerm: + return max(devices, key=lambda item: (len(item[3]), item[2].canonical != "座椅"))[2] + + @staticmethod + def _contains_negation(text: str) -> bool: + return any(marker in text for marker in NEGATION_PREFIXES) or bool( + re.search(r"(?:不能|不可|不可以)[^,,。!?!?]{0,8}(?:打开|开启|调整|调节|移动)", text) + ) + + @staticmethod + def _looks_like_control(text: str) -> bool: + return bool(device_terms_in(text) and action_terms_in(text)) + + @staticmethod + def _scope_qualifier(text: str, resolved_scope: str) -> tuple[bool, str]: + matches = [ + (match.start(), scope, alias) + for scope, aliases in SCOPE_ALIASES.items() + for alias in aliases + for match in re.finditer(re.escape(alias), text) + ] + if not matches: + return False, "" + _, lexical_scope, alias = min(matches) + if resolved_scope == "front_left" and lexical_scope == "driver": + return True, "主驾" + if resolved_scope == "front_right" and lexical_scope == "passenger": + return True, "副驾" + labels = { + "driver": "主驾", + "passenger": "副驾", + "front_row": "前排", + "rear_row": "后排", + "rear_left": "左后", + "rear_right": "右后", + "all": "全部", + "vehicle": "全车", + } + return True, labels.get(lexical_scope, alias) + + @staticmethod + def _numeric_value(text: str) -> int | float | None: + match = _NUMBER.search(text) + if match: + value = float(match.group(1)) + return int(value) if value.is_integer() else value + chinese = re.search(r"[零〇一二两三四五六七八九十百点]+", text) + if not chinese: + return None + token = chinese.group(0) + digits = { + "零": 0, + "〇": 0, + "一": 1, + "二": 2, + "两": 2, + "三": 3, + "四": 4, + "五": 5, + "六": 6, + "七": 7, + "八": 8, + "九": 9, + } + if "点" in token: + whole, fraction = token.split("点", 1) + whole_value = UnderstandingTranscriptProcessor._chinese_integer(whole, digits) + if whole_value is None or not fraction or any(char not in digits for char in fraction): + return None + decimal = "".join(str(digits[char]) for char in fraction) + return float(f"{whole_value}.{decimal}") + return UnderstandingTranscriptProcessor._chinese_integer(token, digits) + + @classmethod + def _percentage_value(cls, text: str) -> int | float | None: + return parse_percentage_value(text) + + @staticmethod + def _chinese_integer(token: str, digits: dict[str, int]) -> int | None: + if not token: + return 0 + if "百" in token: + left, right = token.split("百", 1) + hundreds = digits.get(left[-1], 1) if left else 1 + tail = UnderstandingTranscriptProcessor._chinese_integer(right, digits) + 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 _render(self, commands: list[SpeechCommandIntent]) -> str: + rendered: list[str] = [] + index = 0 + while index < len(commands): + command = commands[index] + if ( + command.property_name == "hvac.power" + and command.operation == "on" + and index + 1 < len(commands) + and commands[index + 1].property_name == "hvac.temperature" + and commands[index + 1].source_text == command.source_text + ): + value = commands[index + 1].parameters.get("temperature_c") + rendered.append(f"打开空调并设置为{self._format_number(value)}度") + index += 2 + continue + rendered.append(self._render_command(command)) + index += 1 + return ",然后".join(part for part in rendered if part) + ("。" if rendered else "") + + def _render_command(self, command: SpeechCommandIntent) -> str: + parameters = command.parameters + prop = command.property_name + if command.resolution_status != "executable" and not parameters: + # A non-executable command must never look successful after its + # critical value/unit was discarded. Preserve the understood + # source meaning so the UI can explain or request clarification. + return command.source_text.strip(",,;;。!?!? ") + if prop == "hvac.power": + return f"{'打开' if command.operation == 'on' else '关闭'}{self._scoped_target(command, '空调')}" + if prop == "hvac.temperature": + return f"将{self._scoped_target(command, '空调')}设置为{self._format_number(parameters.get('temperature_c'))}度" + if prop == "hvac.fan_level": + return f"将空调风量设置为{self._format_number(parameters.get('level'))}档" + if prop in {"seat.ventilation", "seat.heating"}: + feature = "座椅通风" if prop.endswith("ventilation") else "座椅加热" + implicit = set(command.qualifiers.get("implicit_parameters", ())) + if "level" in parameters and "level" not in implicit: + level = command.qualifiers.get("display_level") or ( + f"{self._format_number(parameters['level'])}档" + ) + return f"将{self._scoped_target(command, feature)}设置为{level}" + return f"{'打开' if command.operation == 'on' else '关闭'}{self._scoped_target(command, feature)}" + if prop == "seat.mode": + return f"将{self._scoped_target(command, '座椅')}调整为{command.value}" + if prop == "seat.backrest_recline": + direction = ( + "向后" + if command.operation == "backward" + else "向前" + if command.operation == "forward" + else "" + ) + if "angle_delta_deg" in parameters: + return f"将{self._display_target(command)}{direction}调整{self._format_number(parameters['angle_delta_deg'])}度" + if "angle_deg" in parameters: + return f"将{self._display_target(command)}调整到{self._format_number(parameters['angle_deg'])}度" + if "position_pct" in parameters: + return f"将{self._display_target(command)}调整到{self._format_number(parameters['position_pct'])}%" + suffix = "一点" if "一点" in command.source_text else "" + return f"将{self._display_target(command)}{direction}调整{suffix}" + if prop.startswith("seat."): + direction = ( + "向后" + if command.operation == "backward" + else "向前" + if command.operation == "forward" + else { + "up": "向上", + "down": "向下", + "forward": "向前", + "backward": "向后", + }.get(str(command.qualifiers.get("spoken_direction", "")), "") + ) + if "position_pct" in parameters: + return f"将{self._display_target(command)}{direction}调整到{self._format_number(parameters['position_pct'])}%" + suffix = "一点" if "一点" in command.source_text else "" + return f"将{self._display_target(command)}{direction}调整{suffix}" + if prop == "window.motion": + return f"{'打开' if command.operation == 'open' else '关闭'}{self._display_target(command)}" + if prop == "window.position": + return f"将{self._display_target(command)}打开到{self._format_number(parameters.get('position_pct'))}%" + if prop == "roof.shade_motion": + motion = parameters.get("motion") + return ( + "将遮阳帘打开一半" + if motion == "half" + else f"{'打开' if motion == 'open' else '关闭'}遮阳帘" + ) + if prop == "roof.shade_position": + return f"将遮阳帘打开到{self._format_number(parameters.get('position_pct'))}%" + if prop == "roof.transmittance": + return f"将天幕透光率设置为{self._format_number(parameters.get('transmittance_pct'))}%" + if prop == "ambient.color": + display = command.qualifiers.get("display_value") or command.value + brightness = parameters.get("brightness_pct") + if display == "brightness_only": + return f"将氛围灯亮度设置为{self._format_number(brightness)}%" + return ( + f"将氛围灯颜色调整为{display}并将亮度设置为{self._format_number(brightness)}%" + if brightness is not None + else f"将氛围灯颜色调整为{display}" + ) + if prop == "ambient.brightness": + return f"将氛围灯亮度设置为{self._format_number(parameters.get('brightness_level'))}档" + if prop == "ambient.power": + return f"{'打开' if command.operation == 'on' else '关闭'}氛围灯" + if prop in {"reading_light.power", "indoor_light.power", "front_light.power"}: + return ( + f"{'打开' if command.operation == 'on' else '关闭'}{self._display_target(command)}" + ) + return command.source_text + + @staticmethod + def _display_target(command: SpeechCommandIntent) -> str: + names = { + "hvac.power": "空调", + "hvac.temperature": "空调", + "hvac.fan_level": "空调风量", + "seat.backrest_recline": "座椅靠背", + "seat.longitudinal_position": "座椅", + "seat.cushion_tilt": "座椅坐垫", + "seat.legrest_height": "座椅腿托", + "seat.legrest_extension": "座椅腿托", + "seat.ventilation": "座椅通风", + "seat.heating": "座椅加热", + "window.motion": "车窗", + "window.position": "车窗", + "roof.shade_motion": "遮阳帘", + "roof.shade_position": "遮阳帘", + "roof.transmittance": "天幕", + "ambient.power": "氛围灯", + "ambient.color": "氛围灯", + "reading_light.power": "阅读灯", + "indoor_light.power": "车内灯", + "front_light.power": "前大灯", + } + scope = ( + str(command.qualifiers.get("spoken_scope", "")) + if command.qualifiers.get("target_explicit") + else "" + ) + return f"{scope}{names.get(command.property_name, command.target_device)}" + + @classmethod + def _scoped_target(cls, command: SpeechCommandIntent, target: str) -> str: + scope = ( + str(command.qualifiers.get("spoken_scope", "")) + if command.qualifiers.get("target_explicit") + else "" + ) + return f"{scope}{target}" + + @staticmethod + def _format_number(value: object) -> str: + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + return str(value if value is not None else "") + + @staticmethod + def _semantic_status( + commands: list[SpeechCommandIntent], reasons: tuple[str, ...] + ) -> TranscriptStatus: + statuses = {command.resolution_status for command in commands} + if "incomplete" in statuses: + return "incomplete" + if "ambiguous" in statuses: + return "ambiguous" + if "unsupported" in statuses: + return "recognized_unsupported" + if commands: + return "recognized_executable" + if any("incomplete" in reason or "not_resolved" in reason for reason in reasons): + return "incomplete" + return "no_command" + + @staticmethod + def _semantic_trace( + raw_text: str, + normalized_text: str, + clauses: list[str], + commands: list[SpeechCommandIntent], + clean_text: str, + semantic_status: TranscriptStatus, + ) -> tuple[dict[str, Any], ...]: + clause_slots = [] + for clause in clauses: + clause_slots.append( + { + "text": clause, + "entities": [ + { + "canonical": term.canonical, + "domain": term.domain, + "semantic_key": term.semantic_key, + "source": alias, + } + for _, _, term, alias in device_terms_in(clause) + ], + "operations": [action for _, _, action, _ in action_terms_in(clause)], + "qualifiers": [ + alias + for aliases in SCOPE_ALIASES.values() + for alias in aliases + if alias in clause + ], + "color": color_in(clause), + } + ) + return ( + {"stage": "raw_text", "text": raw_text}, + {"stage": "normalized_text", "text": normalized_text}, + {"stage": "clause_segmentation", "clauses": clause_slots}, + {"stage": "semantic_commands", "commands": [command.to_dict() for command in commands]}, + { + "stage": "intent_ir", + "actions": [ + command.intent_ir_action + for command in commands + if command.intent_ir_action is not None + ], + }, + { + "stage": "capability_resolution", + "results": [ + { + "property": command.property_name, + "capability": command.capability, + "status": command.resolution_status, + "issues": list(command.validation_issues), + "executor_binding": command.executor_binding, + } + for command in commands + ], + }, + { + "stage": "executor_payload", + "payloads": [ + command.executor_payload + for command in commands + if command.executor_payload is not None + ], + }, + {"stage": "clean_text", "text": clean_text, "semantic_status": semantic_status}, + ) + + def _prepare(self, text: str) -> tuple[str, list[str]]: + removed: list[str] = [] + text = _POLITE_PREFIX.sub("", text) + text = _POLITE_SUFFIX.sub("", text) + + def remove(match: re.Match[str]) -> str: + removed.append(match.group(0)) + return "" + + text = _VOCAL_FILLER.sub(remove, text) + text = _ASR_FILLER_BEFORE_VALUE.sub(remove, text) + + def separate_after_value(match: re.Match[str]) -> str: + removed.append(match.group(0)) + return "," + + text = _ASR_FILLER_AFTER_VALUE.sub(separate_after_value, text) + text = _REPEATED_DEMONSTRATIVE.sub(remove, text) + previous = None + while previous != text: + previous = text + text = _LEADING_FILLER.sub(remove, text) + text = self._collapse_lexical_stutters(text) + text = self._collapse_target_echoes(text) + return self._tidy(text), removed + + @staticmethod + def _collapse_lexical_stutters(text: str) -> str: + aliases = sorted( + {alias for values in ACTION_ALIASES.values() for alias in values if len(alias) >= 2}, + key=len, + reverse=True, + ) + for alias in aliases: + if alias.startswith("调"): + continue + text = re.sub(rf"{re.escape(alias[0])}{{1,3}}(?={re.escape(alias)})", "", text) + return text + + @staticmethod + def _collapse_target_echoes(text: str) -> str: + for term in DEVICE_TERMS: + aliases = sorted(term.aliases, key=len, reverse=True) + group = "|".join(map(re.escape, aliases)) + text = re.sub( + rf"(?P{group})(?:的)?(?:靠)?(?P{group})(?=(?:向|往|朝|调|调整|设置|打开|关闭))", + term.canonical, + text, + ) + for alias in aliases: + text = re.sub(rf"(?:{re.escape(alias)}){{2,}}", term.canonical, text) + return text + + @staticmethod + def _tidy(text: str) -> str: + text = re.sub(r"\s+", " ", str(text or "")).strip() + text = re.sub(r"\s*([,。!?;,!?;])\s*", r"\1", text) + text = re.sub(r"^[,,;;。\s]+", "", text) + text = re.sub(r"[,,;;\s]+([。!?!?])", r"\1", text) + text = re.sub(r"[,,]{2,}", ",", text) + return text.strip() + + @staticmethod + def _sentence(text: str) -> str: + return text if _TERMINAL.search(text) else f"{text}。" diff --git a/src/cognitive_agent/infrastructure/speech/vad.py b/src/cognitive_agent/infrastructure/speech/vad.py new file mode 100644 index 0000000..db02eea --- /dev/null +++ b/src/cognitive_agent/infrastructure/speech/vad.py @@ -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, + ) diff --git a/src/cognitive_agent/infrastructure/speech/vocabulary.py b/src/cognitive_agent/infrastructure/speech/vocabulary.py new file mode 100644 index 0000000..744bcaa --- /dev/null +++ b/src/cognitive_agent/infrastructure/speech/vocabulary.py @@ -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 diff --git a/src/cognitive_agent/server.py b/src/cognitive_agent/server.py index e454157..770c5ab 100755 --- a/src/cognitive_agent/server.py +++ b/src/cognitive_agent/server.py @@ -12,28 +12,28 @@ import re import socket import threading import time -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, @@ -42,16 +42,20 @@ 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.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") @@ -176,6 +180,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" @@ -342,6 +347,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 @@ -357,6 +365,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", @@ -482,6 +493,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", "") @@ -596,6 +633,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({"error": "invalid_request", "message": str(exc)}, HTTPStatus.BAD_REQUEST) @@ -1340,6 +1383,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 @@ -1612,7 +1690,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", diff --git a/tests/e2e/test_seat_web_api.py b/tests/e2e/test_seat_web_api.py index bc708ea..6861fb9 100755 --- a/tests/e2e/test_seat_web_api.py +++ b/tests/e2e/test_seat_web_api.py @@ -13,15 +13,18 @@ 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.llm.openai_responses import OpenAIIntentError +from cognitive_agent.infrastructure.speech.understanding import ( + UnderstandingTranscriptProcessor, +) from cognitive_agent.server import SeatDemoHandler @@ -332,7 +335,7 @@ operations: def test_static_console_and_runtime_endpoint(self) -> None: with self.opener.open(f"{self.base_url}/", timeout=3) as response: html = response.read().decode("utf-8") - with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-103", timeout=3) as response: + with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-104", timeout=3) as response: app_script = response.read().decode("utf-8") with self.opener.open( f"{self.base_url}/gesture-control.js?v=android-pad-9", timeout=3 @@ -346,6 +349,18 @@ operations: f"{self.base_url}/console-v2.css?v=sync-layout-41", timeout=3 ) as response: console_styles = response.read().decode("utf-8") + with self.opener.open( + 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=nfc-toy-33", timeout=3 ) as response: @@ -368,6 +383,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) @@ -382,12 +399,16 @@ operations: self.assertIn("CMD 0x431 · FB 0x41A · CH1", html) self.assertIn("CMD 0x431 · FB 0x388 · CH1", html) self.assertNotIn("透明车体 · 实体设备", html) - self.assertIn('src="/app.js?v=dual-seat-103"', html) + self.assertIn('src="/app.js?v=dual-seat-104"', html) self.assertIn('href="/styles.css?v=dual-seat-16"', html) self.assertIn('src="/gesture-control.js?v=android-pad-9"', html) self.assertNotIn('class="vehicle-system-hud"', html) self.assertIn('src="/console-v2.js?v=sync-layout-43"', html) self.assertIn('href="/console-v2.css?v=sync-layout-41"', 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('', html) self.assertIn('id="v2BootScreen"', html) self.assertIn('正在加载车机控制与实时状态', html) @@ -622,7 +643,29 @@ operations: self.assertIn('client_type: "web_console"', app_script) self.assertIn('canvas.addEventListener("webglcontextlost"', app_script) self.assertIn('"cognitive-agent:module-change"', app_script) - self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v210"', service_worker) + self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v216"', 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) @@ -649,6 +692,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) @@ -856,13 +900,164 @@ 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 ) as response: extension_script = response.read().decode("utf-8") with self.opener.open( - f"{self.base_url}/app.js?v=dual-seat-103", timeout=3 + f"{self.base_url}/app.js?v=dual-seat-104", timeout=3 ) as response: app_script = response.read().decode("utf-8") diff --git a/tests/fixtures/speech_text_regression.json b/tests/fixtures/speech_text_regression.json new file mode 100644 index 0000000..d0822a5 --- /dev/null +++ b/tests/fixtures/speech_text_regression.json @@ -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} +] diff --git a/tests/unit/test_speech_capability_alignment.py b/tests/unit/test_speech_capability_alignment.py new file mode 100644 index 0000000..5ffe655 --- /dev/null +++ b/tests/unit/test_speech_capability_alignment.py @@ -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), 43) + 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() diff --git a/tests/unit/test_speech_orchestration_bridge.py b/tests/unit/test_speech_orchestration_bridge.py new file mode 100644 index 0000000..848844c --- /dev/null +++ b/tests/unit/test_speech_orchestration_bridge.py @@ -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] diff --git a/tests/unit/test_speech_transcription.py b/tests/unit/test_speech_transcription.py new file mode 100644 index 0000000..5b28b03 --- /dev/null +++ b/tests/unit/test_speech_transcription.py @@ -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() diff --git a/tests/unit/test_transcript_assembler.py b/tests/unit/test_transcript_assembler.py new file mode 100644 index 0000000..69e2156 --- /dev/null +++ b/tests/unit/test_transcript_assembler.py @@ -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() diff --git a/tools/benchmark_speech_pipeline.py b/tools/benchmark_speech_pipeline.py new file mode 100644 index 0000000..77b6d2c --- /dev/null +++ b/tools/benchmark_speech_pipeline.py @@ -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() diff --git a/tools/install_sensevoice_model.py b/tools/install_sensevoice_model.py new file mode 100644 index 0000000..db2c63b --- /dev/null +++ b/tools/install_sensevoice_model.py @@ -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() diff --git a/tools/report_capability_matrix.py b/tools/report_capability_matrix.py new file mode 100644 index 0000000..32640ad --- /dev/null +++ b/tools/report_capability_matrix.py @@ -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()