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
+309
View File
@@ -0,0 +1,309 @@
from __future__ import annotations
import json
import threading
import urllib.error
import urllib.request
from http.server import ThreadingHTTPServer
from typing import ClassVar
from cognitive_agent.auth import AuthConfig, AuthRuntime
from cognitive_agent.infrastructure.speech import SpeechRecognitionError
from cognitive_agent.server import SeatDemoHandler
class SpeechStub:
preview_calls: ClassVar[list[dict[str, object]]] = []
final_calls: ClassVar[list[dict[str, object]]] = []
preview_error: ClassVar[SpeechRecognitionError | None] = None
@staticmethod
def status() -> dict[str, object]:
return {
"enabled": True,
"model": "SenseVoiceSmall",
"decoder": "offline-sensevoice-live-preview-and-final",
"live_preview": {"enabled": True, "recommended_interval_ms": 850},
"input_modes": {
"supported": ["manual", "wake"],
"header": "X-Speech-Input-Mode",
"preview_default": "wake",
"final_default": "manual",
},
"wake_word": {
"enabled": True,
"primary": "你好,双十一",
"aliases": ["你好,双十一", "你好,双11"],
},
}
@classmethod
def transcribe_preview_wav(
cls,
content: bytes,
*,
utterance_id: str | None = None,
sequence: int,
) -> dict[str, object]:
if cls.preview_error is not None:
raise cls.preview_error
if sequence < 0:
raise SpeechRecognitionError(
"invalid_preview_sequence", "实时转写序号无效。", status=400
)
cls.preview_calls.append(
{
"content": content,
"utterance_id": utterance_id,
"sequence": sequence,
}
)
return {
"utterance_id": utterance_id,
"sequence": sequence,
"state": "partial",
"raw_text": "你好双十一打开空",
"text": "你好双十一打开空",
"live_text": "打开空",
"wake_word": {"detected": True, "remainder": "打开空"},
"semantic_cleaned": False,
"stale": False,
"idempotent_replay": False,
}
@classmethod
def transcribe_wav(
cls,
content: bytes,
*,
utterance_id: str | None = None,
require_wake_word: bool = False,
) -> dict[str, object]:
cls.final_calls.append(
{
"content": content,
"utterance_id": utterance_id,
"require_wake_word": require_wake_word,
}
)
return {
"utterance_id": utterance_id,
"captured_text": "你好双十一车上有点热",
"raw_text": "车上有点热",
"assistant_text": "车上有点热。",
"text": "车上有点热。",
"clean_text": "",
"control_text": "",
"commands": [],
"execution_eligible": False,
"wake_word": {"detected": True, "remainder": "车上有点热"},
}
class SpeechApiHandler(SeatDemoHandler):
speech = SpeechStub()
auth = AuthRuntime(AuthConfig(False, "", "", b"", 3600, 5, 300, 120, False))
class TestSpeechLiveWebApi:
early_rejection_body = b"RIFF" + (b"\0" * (256 * 1024))
@classmethod
def setup_class(cls) -> None:
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), SpeechApiHandler)
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
cls.thread.start()
cls.base_url = f"http://127.0.0.1:{cls.server.server_port}"
@classmethod
def teardown_class(cls) -> None:
cls.server.shutdown()
cls.server.server_close()
cls.thread.join(timeout=3)
def setup_method(self) -> None:
SpeechStub.preview_calls.clear()
SpeechStub.final_calls.clear()
SpeechStub.preview_error = None
@classmethod
def post_audio(
cls,
path: str,
*,
body: bytes = b"RIFF-live-speech",
headers: dict[str, str] | None = None,
) -> tuple[int, dict[str, object]]:
request = urllib.request.Request(
f"{cls.base_url}{path}",
method="POST",
headers={"Content-Type": "audio/wav", **(headers or {})},
data=body,
)
try:
with urllib.request.urlopen(request, timeout=5) as response:
cls.last_response_headers = dict(response.headers.items())
return response.status, json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
cls.last_response_headers = dict(exc.headers.items())
return exc.code, json.loads(exc.read().decode("utf-8"))
def test_status_advertises_live_preview_and_configured_wake_word(self) -> None:
with urllib.request.urlopen(f"{self.base_url}/api/speech/status", timeout=5) as response:
payload = json.loads(response.read().decode("utf-8"))
assert payload["decoder"] == "offline-sensevoice-live-preview-and-final"
assert payload["live_preview"]["enabled"] is True
assert payload["input_modes"] == {
"supported": ["manual", "wake"],
"header": "X-Speech-Input-Mode",
"preview_default": "wake",
"final_default": "manual",
}
assert payload["wake_word"]["enabled"] is True
assert payload["wake_word"]["primary"] == "你好,双十一"
def test_preview_endpoint_forwards_accumulated_audio_identity_and_sequence(self) -> None:
status, payload = self.post_audio(
"/api/speech/preview",
headers={
"X-Speech-Input-Mode": "wake",
"X-Speech-Utterance-Id": "web-live-preview-1",
"X-Speech-Preview-Sequence": "7",
},
)
assert status == 200
assert SpeechStub.preview_calls == [
{
"content": b"RIFF-live-speech",
"utterance_id": "web-live-preview-1",
"sequence": 7,
}
]
assert payload["state"] == "partial"
assert payload["sequence"] == 7
assert payload["raw_text"] == "你好双十一打开空"
assert payload["live_text"] == "打开空"
assert payload["semantic_cleaned"] is False
def test_final_endpoint_requires_wake_and_preserves_assistant_only_text(self) -> None:
status, payload = self.post_audio(
"/api/speech/transcribe",
headers={
"X-Speech-Input-Mode": "wake",
"X-Speech-Utterance-Id": "web-live-final-1",
"X-Speech-Wake-Word-Required": "1",
},
)
assert status == 200
assert SpeechStub.final_calls == [
{
"content": b"RIFF-live-speech",
"utterance_id": "web-live-final-1",
"require_wake_word": True,
}
]
assert payload["raw_text"] == "车上有点热"
assert payload["assistant_text"] == "车上有点热。"
assert payload["text"] == "车上有点热。"
assert payload["control_text"] == ""
assert payload["commands"] == []
assert payload["execution_eligible"] is False
def test_manual_final_endpoint_does_not_require_a_wake_word(self) -> None:
status, payload = self.post_audio(
"/api/speech/transcribe",
headers={
"X-Speech-Input-Mode": "manual",
"X-Speech-Utterance-Id": "web-manual-final-1",
"X-Speech-Wake-Word-Required": "0",
},
)
assert status == 200
assert SpeechStub.final_calls == [
{
"content": b"RIFF-live-speech",
"utterance_id": "web-manual-final-1",
"require_wake_word": False,
}
]
assert payload["assistant_text"] == "车上有点热。"
def test_final_endpoint_defaults_to_manual_when_wake_header_is_absent(self) -> None:
status, _payload = self.post_audio(
"/api/speech/transcribe",
headers={
"X-Speech-Input-Mode": "manual",
"X-Speech-Utterance-Id": "web-manual-default-1",
},
)
assert status == 200
assert SpeechStub.final_calls[0]["require_wake_word"] is False
def test_preview_rejects_missing_sequence_without_invoking_transcriber(self) -> None:
status, payload = self.post_audio(
"/api/speech/preview",
body=self.early_rejection_body,
headers={"X-Speech-Utterance-Id": "web-live-preview-invalid"},
)
assert status == 400
assert payload["error"] == "invalid_preview_sequence"
assert SpeechStub.preview_calls == []
def test_preview_http_gate_rejects_overload_before_reading_or_transcribing(self) -> None:
original_slots = SpeechApiHandler.speech_preview_http_slots
constrained_slots = threading.BoundedSemaphore(1)
SpeechApiHandler.speech_preview_http_slots = constrained_slots
assert constrained_slots.acquire(blocking=False)
try:
status, payload = self.post_audio(
"/api/speech/preview",
body=self.early_rejection_body,
headers={
"X-Speech-Utterance-Id": "web-live-preview-overloaded",
"X-Speech-Preview-Sequence": "0",
},
)
finally:
constrained_slots.release()
SpeechApiHandler.speech_preview_http_slots = original_slots
assert status == 429
assert payload["error"] == "preview_overloaded"
assert payload["retry_after_seconds"] == 1
assert self.last_response_headers["Retry-After"] == "1"
assert SpeechStub.preview_calls == []
def test_preview_transcriber_rate_error_preserves_retry_contract(self) -> None:
SpeechStub.preview_error = SpeechRecognitionError(
"preview_rate_limited",
"实时语音更新过快,请稍后重试。",
status=429,
retry_after_seconds=2,
)
status, payload = self.post_audio(
"/api/speech/preview",
headers={
"X-Speech-Utterance-Id": "web-live-preview-throttled",
"X-Speech-Preview-Sequence": "1",
},
)
assert status == 429
assert payload["error"] == "preview_rate_limited"
assert payload["retry_after_seconds"] == 2
assert self.last_response_headers["Retry-After"] == "2"
def test_live_endpoints_reject_non_wav_content_type(self) -> None:
for path in ("/api/speech/preview", "/api/speech/transcribe"):
status, payload = self.post_audio(
path,
headers={"Content-Type": "application/json"},
)
assert status == 415, path
assert payload["error"] == "unsupported_media_type", path