feat: 完善 N7 智能座舱交互与控制链路

This commit is contained in:
vee
2026-09-07 15:34:59 +08:00
parent 32d179143f
commit c96970f068
100 changed files with 30088 additions and 1174 deletions
+664
View File
@@ -4,6 +4,8 @@ import io
import json
import math
import tempfile
import threading
import time
import unittest
import wave
from pathlib import Path
@@ -16,6 +18,7 @@ from cognitive_agent.infrastructure.speech.sensevoice import (
from cognitive_agent.infrastructure.speech.understanding import (
UnderstandingTranscriptProcessor,
)
from cognitive_agent.infrastructure.speech.wake_word import WakeWordMatcher
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
@@ -52,12 +55,27 @@ class _FakeRecognizer:
def __init__(self, text: str) -> None:
self.text = text
self.decode_count = 0
self.decoded_sample_counts: list[int] = []
def create_stream(self) -> _FakeStream:
return _FakeStream(self.text)
def decode_stream(self, stream: _FakeStream) -> None:
self.decode_count += 1
self.decoded_sample_counts.append(len(stream.samples))
class _BlockingRecognizer(_FakeRecognizer):
def __init__(self, text: str) -> None:
super().__init__(text)
self.decode_started = threading.Event()
self.release_decode = threading.Event()
def decode_stream(self, stream: _FakeStream) -> None:
super().decode_stream(stream)
self.decode_started.set()
if not self.release_decode.wait(timeout=5):
raise RuntimeError("test recognizer was not released")
class UnderstandingRegressionTests(unittest.TestCase):
@@ -73,6 +91,14 @@ class UnderstandingRegressionTests(unittest.TestCase):
result = self.processor.process(case["raw"])
self.assertEqual(result.raw_text, case["raw"])
self.assertEqual(result.clean_text, case["clean"])
self.assertEqual(
result.assistant_text,
case.get("assistant", case["clean"]),
)
serialized = result.to_dict()
self.assertEqual(serialized["text"], result.assistant_text)
self.assertEqual(serialized["assistant_text"], result.assistant_text)
self.assertEqual(serialized["control_text"], result.clean_text)
self.assertEqual(
[command.capability for command in result.commands],
case["capabilities"],
@@ -109,10 +135,175 @@ class UnderstandingRegressionTests(unittest.TestCase):
self.assertEqual(len(result.commands), 2)
self.assertFalse(result.cancelled_fragments)
def test_meaningful_non_control_speech_remains_available_to_assistant(self) -> None:
cases = (
("今天天气怎么样?", "今天天气怎么样", "no_command"),
("我们中午吃什么?", "我们中午吃什么", "no_command"),
("车上有点热。", "车上有点热", "no_command"),
("打开雨刷。", "打开雨刷", "incomplete"),
("空调挺凉快。", "空调挺凉快", "incomplete"),
)
for raw, preserved, status in cases:
with self.subTest(raw=raw):
result = self.processor.process(raw)
self.assertIn(preserved, result.assistant_text)
self.assertTrue(result.assistant_text.strip())
self.assertEqual(result.semantic_status, status)
self.assertFalse(result.execution_eligible)
self.assertFalse(result.commands)
def test_control_and_chat_in_same_spoken_block_are_both_preserved_for_assistant(self) -> None:
result = self.processor.process("打开空调,今天天气怎么样?")
self.assertIn("打开空调", result.assistant_text)
self.assertIn("今天天气怎么样", result.assistant_text)
self.assertEqual(
[command.capability for command in result.commands],
["climate.set_power"],
)
self.assertEqual(result.clean_text, "打开空调。")
self.assertFalse(result.execution_eligible)
self.assertTrue(result.uncertain)
def test_mode_creation_stays_before_its_normalized_control_definition(self) -> None:
result = self.processor.process(
"创建一个躺平模式,副驾座椅水平向前移动到最大位置,车窗关闭"
)
self.assertEqual(
result.assistant_text,
"创建一个躺平模式,将副驾座椅向前调整到0%,然后关闭车窗。",
)
self.assertEqual(result.assistant_text.count("躺平模式"), 1)
self.assertEqual(
[command.capability for command in result.commands],
["seat.set_longitudinal_position", "window.set_motion"],
)
self.assertEqual(result.commands[0].parameters, {"position_pct": 0})
self.assertEqual(
result.clean_text,
"将副驾座椅向前调整到0%,然后关闭车窗。",
)
ordinary_controls = self.processor.process(
"副驾座椅水平向前移动到最大位置,车窗关闭"
)
self.assertEqual(
ordinary_controls.assistant_text,
"将副驾座椅向前调整到0%,然后关闭车窗。",
)
def test_cancelled_control_can_be_followed_by_an_assistant_question(self) -> None:
result = self.processor.process("打开空调,算了,我们聊聊空调为什么制冷。")
self.assertIn("我们聊聊空调为什么制冷", result.assistant_text)
self.assertNotIn("打开空调", result.assistant_text)
self.assertFalse(result.commands)
self.assertFalse(result.execution_eligible)
self.assertEqual(len(result.cancelled_fragments), 1)
self.assertEqual(
[command.capability for command in result.cancelled_fragments[0].commands],
["climate.set_power"],
)
def test_general_question_correction_keeps_only_latest_meaningful_request(self) -> None:
result = self.processor.process("帮我查北京天气,不对,上海天气。")
self.assertIn("上海天气", result.assistant_text)
self.assertNotIn("北京天气", result.assistant_text)
self.assertFalse(result.commands)
self.assertFalse(result.execution_eligible)
self.assertEqual(len(result.cancelled_fragments), 1)
def test_general_questions_do_not_split_connector_characters_inside_words(self) -> None:
cases = (
"再生能源是什么?",
"并发编程是什么意思?",
"先锋队是什么意思?",
"最后生还者讲了什么?",
"我想了解再生能源和并发编程。",
"不对称加密是什么?",
"如何把PDF改成Word?",
)
for raw in cases:
with self.subTest(raw=raw):
result = self.processor.process(raw)
self.assertEqual(result.assistant_text, raw)
self.assertFalse(result.commands)
self.assertFalse(result.execution_eligible)
def test_control_and_chat_joined_by_coordination_are_both_preserved(self) -> None:
result = self.processor.process("打开空调和告诉我天气")
self.assertIn("打开空调", result.assistant_text)
self.assertIn("告诉我天气", result.assistant_text)
self.assertEqual(
[command.capability for command in result.commands],
["climate.set_power"],
)
self.assertEqual(result.clean_text, "打开空调。")
self.assertTrue(result.uncertain)
self.assertFalse(result.execution_eligible)
def test_negated_control_remains_conversational_but_never_executable(self) -> None:
result = self.processor.process("不要打开空调,告诉我空调为什么制冷。")
self.assertIn("不要打开空调", result.assistant_text)
self.assertIn("告诉我空调为什么制冷", result.assistant_text)
self.assertFalse(result.commands)
self.assertFalse(result.execution_eligible)
self.assertTrue(result.cancelled_fragments)
self.assertTrue(
all(
command.negated and command.status == "cancelled"
for fragment in result.cancelled_fragments
for command in fragment.commands
)
)
def test_multiple_devices_numbers_and_decimal_values_keep_clause_binding(self) -> None:
result = self.processor.process(
"把主驾温度调到22.5度,副驾调到24度,再把风量调到2档。"
)
self.assertTrue(result.execution_eligible)
self.assertEqual(
[
(command.capability, command.target_scope, command.parameters)
for command in result.commands
],
[
("climate.set_temperature", "driver", {"temperature_c": 22.5}),
("climate.set_temperature", "passenger", {"temperature_c": 24}),
("climate.set_fan_level", "vehicle", {"level": 2}),
],
)
for token in ("主驾", "22.5度", "副驾", "24度", "2档"):
self.assertIn(token, result.assistant_text)
def test_number_correction_does_not_steal_other_device_value(self) -> None:
result = self.processor.process(
"主驾温度调到22度,不对,22.5度,然后副驾调到24度。"
)
self.assertTrue(result.execution_eligible)
temperatures = [
(command.target_scope, command.parameters["temperature_c"])
for command in result.commands
if command.capability == "climate.set_temperature"
]
self.assertEqual(temperatures, [("driver", 22.5), ("passenger", 24)])
self.assertNotIn("22度", result.assistant_text)
self.assertIn("22.5度", result.assistant_text)
self.assertIn("24度", result.assistant_text)
class SpeechTranscriberTests(unittest.TestCase):
def make_transcriber(self, directory: str, text: str) -> tuple[SpeechTranscriber, _FakeRecognizer]:
transcriber = SpeechTranscriber(Path(directory))
# Most unit calls intentionally run back-to-back. Production keeps a
# 500 ms floor, which has dedicated rate-limit coverage below.
transcriber.preview_min_interval_ms = 0
recognizer = _FakeRecognizer(text)
transcriber._recognizer = recognizer
return transcriber, recognizer
@@ -142,6 +333,433 @@ class SpeechTranscriberTests(unittest.TestCase):
self.assertEqual(result["commands"], [])
self.assertFalse(result["execution_eligible"])
def test_general_question_is_returned_as_assistant_text_without_control(self) -> None:
raw = "什么是CAN FD?"
with tempfile.TemporaryDirectory() as directory:
transcriber, _ = self.make_transcriber(directory, raw)
result = transcriber.transcribe_wav(
pcm16_wav(), utterance_id="unit-general-question"
)
self.assertEqual(result["raw_text"], raw)
self.assertIn("什么是CAN FD", result["assistant_text"])
self.assertEqual(result["text"], result["assistant_text"])
self.assertEqual(result["clean_text"], "")
self.assertEqual(result["control_text"], "")
self.assertEqual(result["commands"], [])
self.assertFalse(result["execution_eligible"])
def test_final_wake_word_transcription_strips_only_the_wake_prefix(self) -> None:
captured = "你好,双十一,车上有点热。"
with tempfile.TemporaryDirectory() as directory:
transcriber, _ = self.make_transcriber(directory, captured)
result = transcriber.transcribe_wav(
pcm16_wav(),
utterance_id="unit-wake-final",
require_wake_word=True,
)
self.assertEqual(result["captured_text"], captured)
self.assertEqual(result["raw_text"], "车上有点热。")
self.assertEqual(result["assistant_text"], "车上有点热。")
self.assertTrue(result["wake_word"]["detected"])
self.assertEqual(result["wake_word"]["remainder"], "车上有点热。")
self.assertFalse(result["execution_eligible"])
def test_final_transcription_rejects_missing_or_wake_only_audio(self) -> None:
cases = (
("车上有点热。", "wake_word_missing"),
("你好,双十一。", "empty_after_wake_word"),
)
for index, (raw, code) in enumerate(cases):
with self.subTest(raw=raw), tempfile.TemporaryDirectory() as directory:
transcriber, _ = self.make_transcriber(directory, raw)
with self.assertRaises(SpeechRecognitionError) as caught:
transcriber.transcribe_wav(
pcm16_wav(),
utterance_id=f"unit-wake-rejected-{index}",
require_wake_word=True,
)
self.assertEqual(caught.exception.code, code)
def test_live_preview_is_raw_semantic_free_and_wake_prefix_aware(self) -> None:
with tempfile.TemporaryDirectory() as directory:
transcriber, recognizer = self.make_transcriber(
directory, "你好双十一打开空"
)
first_audio = pcm16_wav(seconds=0.5)
first = transcriber.transcribe_preview_wav(
first_audio,
utterance_id="unit-preview-live",
sequence=0,
)
recognizer.text = "你好双十一打开空调到24.5度"
second_audio = pcm16_wav(frequency=330, seconds=0.8)
second = transcriber.transcribe_preview_wav(
second_audio,
utterance_id="unit-preview-live",
sequence=1,
)
self.assertEqual(first["state"], "partial")
self.assertEqual(first["raw_text"], "你好双十一打开空")
self.assertEqual(first["live_text"], "打开空")
self.assertFalse(first["semantic_cleaned"])
self.assertEqual(second["sequence"], 1)
self.assertEqual(second["raw_text"], "你好双十一打开空调到24.5度")
self.assertEqual(second["live_text"], "打开空调到24.5度")
self.assertTrue(second["wake_word"]["detected"])
self.assertEqual(recognizer.decode_count, 2)
def test_live_preview_duplicate_stale_and_conflicting_sequences_are_safe(self) -> None:
first_audio = pcm16_wav(seconds=0.5)
second_audio = pcm16_wav(frequency=330, seconds=0.8)
conflicting_audio = pcm16_wav(frequency=440, seconds=0.8)
with tempfile.TemporaryDirectory() as directory:
transcriber, recognizer = self.make_transcriber(
directory, "你好双十一打开空调"
)
first = transcriber.transcribe_preview_wav(
first_audio,
utterance_id="unit-preview-order",
sequence=0,
)
recognizer.text = "你好双十一打开空调到24度"
latest = transcriber.transcribe_preview_wav(
second_audio,
utterance_id="unit-preview-order",
sequence=1,
)
duplicate = transcriber.transcribe_preview_wav(
second_audio,
utterance_id="unit-preview-order",
sequence=1,
)
stale = transcriber.transcribe_preview_wav(
first_audio,
utterance_id="unit-preview-order",
sequence=0,
)
with self.assertRaises(SpeechRecognitionError) as caught:
transcriber.transcribe_preview_wav(
conflicting_audio,
utterance_id="unit-preview-order",
sequence=1,
)
self.assertFalse(first["idempotent_replay"])
self.assertFalse(latest["stale"])
self.assertTrue(duplicate["idempotent_replay"])
self.assertFalse(duplicate["stale"])
self.assertEqual(duplicate["live_text"], "打开空调到24度")
self.assertTrue(stale["stale"])
self.assertTrue(stale["idempotent_replay"])
self.assertEqual(stale["requested_sequence"], 0)
self.assertEqual(stale["sequence"], 1)
for sensitive_key in ("raw_text", "text", "live_text", "wake_word"):
self.assertNotIn(sensitive_key, stale)
self.assertEqual(caught.exception.code, "preview_sequence_conflict")
self.assertEqual(recognizer.decode_count, 2)
def test_concurrent_identical_preview_decodes_once_and_inflight_conflict_is_409(
self,
) -> None:
audio = pcm16_wav(seconds=0.5)
conflicting_audio = pcm16_wav(frequency=440, seconds=0.5)
with tempfile.TemporaryDirectory() as directory:
transcriber = SpeechTranscriber(Path(directory))
transcriber.preview_min_interval_ms = 0
recognizer = _BlockingRecognizer("你好双十一打开空调")
transcriber._recognizer = recognizer
waiter_entered = threading.Event()
original_wait = transcriber._wait_for_preview
def observed_wait(inflight: object) -> dict[str, object]:
waiter_entered.set()
return original_wait(inflight) # type: ignore[arg-type]
transcriber._wait_for_preview = observed_wait # type: ignore[method-assign]
results: list[dict[str, object]] = []
failures: list[BaseException] = []
def request() -> None:
try:
results.append(
transcriber.transcribe_preview_wav(
audio,
utterance_id="unit-preview-concurrent",
sequence=3,
)
)
except Exception as exc: # noqa: BLE001 # pragma: no cover - assertion aid
failures.append(exc)
owner = threading.Thread(target=request)
owner.start()
self.assertTrue(recognizer.decode_started.wait(timeout=2))
with self.assertRaises(SpeechRecognitionError) as conflict:
transcriber.transcribe_preview_wav(
conflicting_audio,
utterance_id="unit-preview-concurrent",
sequence=3,
)
waiter = threading.Thread(target=request)
waiter.start()
self.assertTrue(waiter_entered.wait(timeout=2))
recognizer.release_decode.set()
owner.join(timeout=2)
waiter.join(timeout=2)
self.assertFalse(owner.is_alive())
self.assertFalse(waiter.is_alive())
self.assertEqual(conflict.exception.code, "preview_sequence_conflict")
self.assertEqual(failures, [])
self.assertEqual(recognizer.decode_count, 1)
self.assertEqual(len(results), 2)
self.assertEqual(
sorted(bool(item["idempotent_replay"]) for item in results),
[False, True],
)
self.assertEqual({item["live_text"] for item in results}, {"打开空调"})
def test_preview_global_capacity_rejects_immediately(self) -> None:
with tempfile.TemporaryDirectory() as directory:
transcriber, recognizer = self.make_transcriber(
directory, "你好双十一打开空调"
)
transcriber._preview_request_slots = threading.BoundedSemaphore(1)
self.assertTrue(transcriber._preview_request_slots.acquire(blocking=False))
started = time.perf_counter()
try:
with self.assertRaises(SpeechRecognitionError) as caught:
transcriber.transcribe_preview_wav(
pcm16_wav(seconds=0.5),
utterance_id="unit-preview-overload",
sequence=0,
)
finally:
transcriber._preview_request_slots.release()
self.assertLess(time.perf_counter() - started, 0.2)
self.assertEqual(caught.exception.code, "preview_overloaded")
self.assertEqual(caught.exception.status, 429)
self.assertEqual(caught.exception.retry_after_seconds, 1)
self.assertEqual(recognizer.decode_count, 0)
def test_preview_per_utterance_interval_and_update_limit_are_enforced(self) -> None:
audio = pcm16_wav(seconds=0.5)
with tempfile.TemporaryDirectory() as directory:
throttled, _ = self.make_transcriber(
directory, "你好双十一打开空调"
)
throttled.preview_min_interval_ms = 10_000
throttled.transcribe_preview_wav(
audio, utterance_id="unit-preview-throttle", sequence=0
)
with self.assertRaises(SpeechRecognitionError) as rate_limited:
throttled.transcribe_preview_wav(
pcm16_wav(frequency=330, seconds=0.6),
utterance_id="unit-preview-throttle",
sequence=1,
)
limited, _ = self.make_transcriber(directory, "你好双十一打开空调")
limited.preview_max_updates = 1
limited.transcribe_preview_wav(
audio, utterance_id="unit-preview-count", sequence=0
)
with self.assertRaises(SpeechRecognitionError) as count_limited:
limited.transcribe_preview_wav(
pcm16_wav(frequency=330, seconds=0.6),
utterance_id="unit-preview-count",
sequence=1,
)
self.assertEqual(rate_limited.exception.code, "preview_rate_limited")
self.assertEqual(rate_limited.exception.status, 429)
self.assertGreaterEqual(rate_limited.exception.retry_after_seconds or 0, 1)
self.assertEqual(count_limited.exception.code, "preview_limit_reached")
self.assertEqual(count_limited.exception.status, 429)
def test_windowed_preview_returns_complete_accumulated_live_text(self) -> None:
with tempfile.TemporaryDirectory() as directory:
transcriber, recognizer = self.make_transcriber(
directory, "你好双十一打开空调"
)
transcriber.preview_window_ms = 400
first = transcriber.transcribe_preview_wav(
pcm16_wav(seconds=0.3),
utterance_id="unit-preview-window",
sequence=0,
)
# The first tail window may begin inside the spoken wake phrase.
# Its remainder must not be appended to the assistant input.
recognizer.text = "好双十一打开空调到24度然后打开遮阳帘"
second = transcriber.transcribe_preview_wav(
pcm16_wav(seconds=1.2),
utterance_id="unit-preview-window",
sequence=1,
)
self.assertEqual(first["live_text"], "打开空调")
self.assertEqual(
second["raw_text"], "好双十一打开空调到24度然后打开遮阳帘"
)
self.assertEqual(second["live_text"], "打开空调到24度然后打开遮阳帘")
self.assertTrue(second["wake_word"]["detected"])
self.assertTrue(second["wake_word"]["latched"])
self.assertEqual(
second["wake_word"]["remainder"],
second["live_text"],
)
self.assertEqual(recognizer.decoded_sample_counts, [4_800, 6_400])
self.assertTrue(second["preview_window"]["bounded"])
self.assertEqual(second["preview_window"]["start_ms"], 800)
self.assertTrue(second["preview_window"]["complete_live_text"])
def test_unawakened_fixed_prefix_is_not_decoded_repeatedly(self) -> None:
with tempfile.TemporaryDirectory() as directory:
transcriber, recognizer = self.make_transcriber(directory, "普通环境声音")
transcriber.preview_window_ms = 400
first = transcriber.transcribe_preview_wav(
pcm16_wav(seconds=0.6),
utterance_id="unit-preview-unawakened-prefix",
sequence=0,
)
recognizer.text = "这段内容不应被再次解码"
second = transcriber.transcribe_preview_wav(
pcm16_wav(seconds=0.9),
utterance_id="unit-preview-unawakened-prefix",
sequence=1,
)
self.assertFalse(first["wake_word"]["detected"])
self.assertEqual(first["live_text"], "")
self.assertEqual(second["live_text"], "")
self.assertEqual(second["raw_text"], "普通环境声音")
self.assertEqual(second["duration_ms"], 900)
self.assertTrue(second["preview_window"]["reused"])
self.assertEqual(recognizer.decode_count, 1)
def test_final_transcription_has_priority_over_new_preview_work(self) -> None:
with tempfile.TemporaryDirectory() as directory:
transcriber = SpeechTranscriber(Path(directory))
transcriber.preview_min_interval_ms = 0
recognizer = _BlockingRecognizer("什么是CAN FD?")
transcriber._recognizer = recognizer
final_failures: list[BaseException] = []
def finalize() -> None:
try:
transcriber.transcribe_wav(
pcm16_wav(), utterance_id="unit-final-priority"
)
except Exception as exc: # noqa: BLE001 # pragma: no cover - assertion aid
final_failures.append(exc)
final_thread = threading.Thread(target=finalize)
final_thread.start()
self.assertTrue(recognizer.decode_started.wait(timeout=2))
with self.assertRaises(SpeechRecognitionError) as caught:
transcriber.transcribe_preview_wav(
pcm16_wav(seconds=0.5),
utterance_id="unit-preview-during-final",
sequence=0,
)
recognizer.release_decode.set()
final_thread.join(timeout=2)
self.assertFalse(final_thread.is_alive())
self.assertEqual(final_failures, [])
self.assertEqual(caught.exception.code, "preview_finalizing")
self.assertEqual(caught.exception.status, 429)
self.assertEqual(recognizer.decode_count, 1)
def test_background_warmup_is_nonblocking_and_reports_loading_ready_error(
self,
) -> None:
with tempfile.TemporaryDirectory() as directory:
transcriber = SpeechTranscriber(Path(directory))
recognizer = _FakeRecognizer("ready")
loading = threading.Event()
release = threading.Event()
def delayed_load() -> _FakeRecognizer:
loading.set()
if not release.wait(timeout=5):
raise RuntimeError("test warmup was not released")
transcriber._recognizer = recognizer
return recognizer
transcriber._get_recognizer = delayed_load # type: ignore[method-assign]
started = time.perf_counter()
transcriber.start()
self.assertLess(time.perf_counter() - started, 0.2)
self.assertTrue(loading.wait(timeout=2))
self.assertEqual(transcriber.status()["load_state"], "loading")
release.set()
assert transcriber._warmup_thread is not None
transcriber._warmup_thread.join(timeout=2)
ready_status = transcriber.status()
failed = SpeechTranscriber(Path(directory))
def failed_load() -> object:
raise SpeechRecognitionError(
"model_unavailable", "model missing", status=503
)
failed._get_recognizer = failed_load # type: ignore[method-assign]
failed.start()
assert failed._warmup_thread is not None
failed._warmup_thread.join(timeout=2)
failed_status = failed.status()
self.assertFalse(transcriber._warmup_thread.is_alive())
self.assertEqual(ready_status["load_state"], "ready")
self.assertTrue(ready_status["loaded"])
self.assertEqual(ready_status["warmup"]["state"], "ready")
self.assertFalse(failed._warmup_thread.is_alive())
self.assertEqual(failed_status["load_state"], "error")
self.assertEqual(failed_status["warmup"]["error"], "model_unavailable")
def test_preview_without_wake_word_never_exposes_live_assistant_text(self) -> None:
with tempfile.TemporaryDirectory() as directory:
transcriber, _ = self.make_transcriber(directory, "打开空调")
result = transcriber.transcribe_preview_wav(
pcm16_wav(seconds=0.5),
utterance_id="unit-preview-not-awake",
sequence=0,
)
self.assertEqual(result["raw_text"], "打开空调")
self.assertEqual(result["live_text"], "")
self.assertFalse(result["wake_word"]["detected"])
def test_preview_and_final_can_share_utterance_without_cache_collision(self) -> None:
audio = pcm16_wav(seconds=0.8)
with tempfile.TemporaryDirectory() as directory:
transcriber, recognizer = self.make_transcriber(
directory, "你好双十一打开空"
)
preview = transcriber.transcribe_preview_wav(
audio,
utterance_id="unit-preview-final",
sequence=0,
)
recognizer.text = "你好双十一打开空调"
final = transcriber.transcribe_wav(
audio,
utterance_id="unit-preview-final",
require_wake_word=True,
)
self.assertEqual(preview["state"], "partial")
self.assertEqual(final["raw_text"], "打开空调")
self.assertEqual(final["clean_text"], "打开空调。")
self.assertEqual(final["assistant_text"], "打开空调。")
self.assertEqual(recognizer.decode_count, 2)
def test_same_utterance_and_audio_is_decoded_once(self) -> None:
audio = pcm16_wav()
with tempfile.TemporaryDirectory() as directory:
@@ -186,5 +804,51 @@ class SpeechTranscriberTests(unittest.TestCase):
self.assertEqual(caught.exception.code, "invalid_utterance_id")
class WakeWordMatcherTests(unittest.TestCase):
def test_default_phrase_accepts_punctuation_spacing_and_numeric_alias(self) -> None:
matcher = WakeWordMatcher(enabled=True)
cases = (
("你好,双十一,打开空调", "打开空调"),
("你好双十一打开空调", "打开空调"),
("嗯, 你好 双 11,打开空调", "打开空调"),
("请,你好:双十一。什么是CAN FD", "什么是CAN FD"),
)
for text, remainder in cases:
with self.subTest(text=text):
result = matcher.match(text)
self.assertTrue(result.detected)
self.assertEqual(result.remainder, remainder)
def test_wake_phrase_is_anchored_to_the_start_and_avoids_false_positives(self) -> None:
matcher = WakeWordMatcher(enabled=True)
for text in (
"有人说你好双十一会唤醒吗",
"双十一购物节有什么活动",
"你好小十一打开空调",
"你好十一打开空调",
):
with self.subTest(text=text):
result = matcher.match(text)
self.assertFalse(result.detected)
self.assertEqual(result.remainder, "")
def test_wake_phrase_recovers_common_self_repetition_without_leaking_it(self) -> None:
matcher = WakeWordMatcher(enabled=True)
for text in (
"你你好双十一打开空调",
"你好你好双十一打开空调",
):
with self.subTest(text=text):
result = matcher.match(text)
self.assertTrue(result.detected)
self.assertEqual(result.remainder, "打开空调")
def test_disabled_matcher_preserves_input_without_activating(self) -> None:
matcher = WakeWordMatcher(enabled=False)
result = matcher.match("你好,双十一,打开空调")
self.assertFalse(result.detected)
self.assertEqual(result.remainder, "你好,双十一,打开空调")
if __name__ == "__main__":
unittest.main()