feat: 完善 N7 智能座舱交互与控制链路
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.codex_assistant import (
|
||||
CodexAssistantService,
|
||||
_authorize_vehicle_request,
|
||||
)
|
||||
from cognitive_agent.application.native_can_runtime import NativeCanExecutor
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
from cognitive_agent.infrastructure.speech.understanding import (
|
||||
UnderstandingTranscriptProcessor,
|
||||
)
|
||||
from tests.unit.test_codex_assistant_service import CONTROL_TERMS, FakeCodexClient
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def speech_processor() -> UnderstandingTranscriptProcessor:
|
||||
return UnderstandingTranscriptProcessor(REPOSITORY_ROOT)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("spoken_duration", "duration_ms"),
|
||||
(
|
||||
("3s", 3_000),
|
||||
("3秒", 3_000),
|
||||
("三秒", 3_000),
|
||||
("1.5s", 1_500),
|
||||
("1.5秒", 1_500),
|
||||
),
|
||||
)
|
||||
@pytest.mark.parametrize("backrest_alias", ("座椅靠背", "椅背"))
|
||||
def test_spoken_preview_keeps_explicit_timed_backrest_semantics(
|
||||
speech_processor: UnderstandingTranscriptProcessor,
|
||||
spoken_duration: str,
|
||||
duration_ms: int,
|
||||
backrest_alias: str,
|
||||
) -> None:
|
||||
raw = (
|
||||
f"主驾{backrest_alias}向后移动{spoken_duration},"
|
||||
"先不要下发,给出解析的控制指令"
|
||||
)
|
||||
|
||||
result = speech_processor.process(raw)
|
||||
|
||||
assert len(result.commands) == 1
|
||||
command = result.commands[0]
|
||||
assert command.capability == "seat.set_driver_axis_native"
|
||||
assert command.target_scope == "driver"
|
||||
assert command.operation == "backward"
|
||||
assert command.parameters == {
|
||||
"axis": "backrest",
|
||||
"direction": "backward",
|
||||
"duration_ms": duration_ms,
|
||||
}
|
||||
seconds = f"{duration_ms / 1_000:g}秒"
|
||||
assert seconds in result.clean_text
|
||||
assert seconds in result.assistant_text
|
||||
assert f"{duration_ms / 1_000:g}度" not in result.assistant_text
|
||||
assert "不要下发" in result.assistant_text
|
||||
assert "解析的控制指令" in result.assistant_text
|
||||
assert result.execution_eligible is False
|
||||
|
||||
|
||||
def test_reported_three_second_preview_is_preserved_exactly_and_fails_closed(
|
||||
speech_processor: UnderstandingTranscriptProcessor,
|
||||
) -> None:
|
||||
raw = "主驾座椅靠背向后移动3s,先不要下发,给出解析的控制指令"
|
||||
|
||||
result = speech_processor.process(raw)
|
||||
|
||||
assert result.clean_text == "将主驾座椅靠背向后移动3秒。"
|
||||
assert result.assistant_text == (
|
||||
"将主驾座椅靠背向后移动3秒,先不要下发,给出解析的控制指令。"
|
||||
)
|
||||
command = result.commands[0]
|
||||
assert command.resolution_status == "unsupported"
|
||||
assert any("高于工程上限2000" in issue for issue in command.validation_issues)
|
||||
assert result.semantic_status == "recognized_unsupported"
|
||||
assert result.uncertain is True
|
||||
assert result.execution_eligible is False
|
||||
|
||||
|
||||
def test_reported_three_second_preview_cannot_enter_the_service_execution_bridge(
|
||||
tmp_path: Path,
|
||||
speech_processor: UnderstandingTranscriptProcessor,
|
||||
) -> None:
|
||||
transcript = speech_processor.process(
|
||||
"主驾座椅靠背向后移动3s,先不要下发,给出解析的控制指令"
|
||||
)
|
||||
understanding = transcript.to_dict()
|
||||
understanding["utterance_id"] = "reported-timed-preview-safety-gate"
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service.artifact_root = tmp_path / "artifacts"
|
||||
|
||||
with patch.object(
|
||||
NativeCanExecutor,
|
||||
"execute",
|
||||
side_effect=AssertionError("preview-only speech reached physical CAN"),
|
||||
) as physical_execute, pytest.raises(ValueError, match="not execution eligible"):
|
||||
service.process(
|
||||
transcript.clean_text,
|
||||
provider="local",
|
||||
conversation_id="conv-reported-timed-preview-gate",
|
||||
execution_mode="dry_run",
|
||||
vehicle_id="denza_n7",
|
||||
_speech_understanding=understanding,
|
||||
)
|
||||
|
||||
assert physical_execute.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"preview_request",
|
||||
(
|
||||
"先不要下发,给出解析的控制指令",
|
||||
"只解析不要执行",
|
||||
"仅预览",
|
||||
"不要执行",
|
||||
"不下发",
|
||||
"不要发送到车辆",
|
||||
"只给出控制指令",
|
||||
"仅返回解析结果",
|
||||
),
|
||||
)
|
||||
def test_preview_only_phrases_cannot_authorize_vehicle_execution(
|
||||
speech_processor: UnderstandingTranscriptProcessor,
|
||||
preview_request: str,
|
||||
) -> None:
|
||||
result = speech_processor.process(
|
||||
f"主驾座椅靠背向后移动2秒,{preview_request}"
|
||||
)
|
||||
|
||||
authorization = _authorize_vehicle_request(result.assistant_text, CONTROL_TERMS)
|
||||
|
||||
assert result.commands[0].parameters["duration_ms"] == 2_000
|
||||
assert result.execution_eligible is False
|
||||
assert authorization.instruction is None
|
||||
assert authorization.reason == "denied_by_user"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"preview_request",
|
||||
(
|
||||
"先不要下发,给出解析的控制指令",
|
||||
"只解析不要执行",
|
||||
"仅预览",
|
||||
"不要执行",
|
||||
"不下发",
|
||||
"不要发送到车辆",
|
||||
),
|
||||
)
|
||||
def test_spoken_preview_routes_to_answer_only_and_never_calls_control_service(
|
||||
speech_processor: UnderstandingTranscriptProcessor,
|
||||
preview_request: str,
|
||||
) -> None:
|
||||
transcript = speech_processor.process(
|
||||
f"主驾座椅靠背向后移动2秒,{preview_request}"
|
||||
)
|
||||
client = FakeCodexClient()
|
||||
assistant = CodexAssistantService(client)
|
||||
events: list[dict[str, object]] = []
|
||||
control_calls: list[str] = []
|
||||
|
||||
def unexpected_control(
|
||||
instruction: str,
|
||||
_progress: Callable[[dict[str, object]], None],
|
||||
) -> dict[str, object]:
|
||||
control_calls.append(instruction)
|
||||
pytest.fail("a preview-only speech request reached vehicle execution")
|
||||
|
||||
result = assistant.stream(
|
||||
transcript.assistant_text,
|
||||
conversation_id="conv-speech-preview-answer-only",
|
||||
principal="speech-preview-matrix",
|
||||
history=[],
|
||||
emit=lambda event: events.append(event) or True,
|
||||
execute_control=unexpected_control,
|
||||
control_terms=CONTROL_TERMS,
|
||||
)
|
||||
|
||||
assert result["route"] == "agent"
|
||||
assert control_calls == []
|
||||
assert not any(str(event.get("type", "")).startswith("action.") for event in events)
|
||||
assert events[0]["route"] == "agent"
|
||||
assert events[-1]["type"] == "answer.completed"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "duration_ms"),
|
||||
(
|
||||
("主驾座椅靠背向后移动2s", 2_000),
|
||||
("主驾座椅靠背向后移动2秒", 2_000),
|
||||
("主驾椅背向后移动二秒", 2_000),
|
||||
("主驾椅背向后移动1.5s", 1_500),
|
||||
),
|
||||
)
|
||||
def test_ordinary_timed_speech_reaches_only_the_dry_run_service_bridge(
|
||||
tmp_path: Path,
|
||||
speech_processor: UnderstandingTranscriptProcessor,
|
||||
raw: str,
|
||||
duration_ms: int,
|
||||
) -> None:
|
||||
transcript = speech_processor.process(raw)
|
||||
understanding = transcript.to_dict()
|
||||
understanding["utterance_id"] = "timed-speech-preview-matrix"
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service.artifact_root = tmp_path / "artifacts"
|
||||
|
||||
assert transcript.execution_eligible is True
|
||||
assert transcript.commands[0].parameters["duration_ms"] == duration_ms
|
||||
with patch.object(
|
||||
NativeCanExecutor,
|
||||
"execute",
|
||||
side_effect=AssertionError("dry-run speech reached the physical CAN executor"),
|
||||
) as physical_execute:
|
||||
result = service.process(
|
||||
transcript.clean_text,
|
||||
provider="local",
|
||||
conversation_id="conv-timed-speech-preview-matrix",
|
||||
execution_mode="dry_run",
|
||||
vehicle_id="denza_n7",
|
||||
_speech_understanding=understanding,
|
||||
)
|
||||
|
||||
assert physical_execute.call_count == 0
|
||||
assert result["status"] == "succeeded"
|
||||
assert result["execution_mode"] == "dry_run"
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
assert result["provider_used"] == "speech_semantic"
|
||||
actions = result["intent_ir"]["spec"]["actions"]
|
||||
assert len(actions) == 1
|
||||
assert actions[0]["capability"] == "seat.set_driver_axis_native"
|
||||
assert actions[0]["parameters"] == {
|
||||
"axis": "backrest",
|
||||
"direction": "backward",
|
||||
"duration_ms": duration_ms,
|
||||
}
|
||||
command = result["command_plans"]["native_seat"]["spec"]["commands"][0]
|
||||
special_parameters = json.loads(command["signals"]["special_parameters"])
|
||||
assert special_parameters["duration_ms"] == duration_ms
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
(
|
||||
"不要让主驾座椅靠背向后移动2秒,告诉我这样有什么影响",
|
||||
"如果主驾座椅靠背向后移动2秒会怎样",
|
||||
"请解释‘主驾座椅靠背向后移动2秒’是什么意思",
|
||||
"我们聊聊主驾座椅靠背向后移动2秒,但不要执行",
|
||||
),
|
||||
)
|
||||
def test_negation_hypothetical_and_chat_boundaries_never_become_executable(
|
||||
speech_processor: UnderstandingTranscriptProcessor,
|
||||
raw: str,
|
||||
) -> None:
|
||||
transcript = speech_processor.process(raw)
|
||||
authorization = _authorize_vehicle_request(transcript.assistant_text, CONTROL_TERMS)
|
||||
|
||||
assert transcript.execution_eligible is False
|
||||
assert authorization.instruction is None
|
||||
Reference in New Issue
Block a user