Files
Cognitive_Agent/tests/integration/test_natural_can_command_combinations.py
T
2026-08-25 11:39:41 +08:00

320 lines
13 KiB
Python

from __future__ import annotations
import json
import os
import tempfile
import unittest
from collections import Counter
from pathlib import Path
from unittest.mock import patch
from cognitive_agent.application.mqtt_ota_seat_executor import MqttOtaSeatExecutor
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
CATALOG_PATH = (
REPOSITORY_ROOT
/ "tests"
/ "fixtures"
/ "natural_can_command_combination_suite.json"
)
HARDWARE_DISABLED_ENV = {
"COGNITIVE_AGENT_CH1_HIL_ENABLED": "false",
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
}
class NaturalCanCommandCombinationTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.temporary_directory = tempfile.TemporaryDirectory()
temporary_root = Path(cls.temporary_directory.name)
memory_path = temporary_root / "driver-seat-memory.json"
memory_path.write_text(
json.dumps(
{
"version": 1,
"positions": {
str(slot): {
"payload_15": "15 00 00 00 00 00 00 00",
"payload_16": "16 00 00 00 00 00 00 00",
}
for slot in (1, 2, 3)
},
}
),
encoding="utf-8",
)
cls.environment = patch.dict(
os.environ,
{
**HARDWARE_DISABLED_ENV,
"COGNITIVE_AGENT_DRIVER_SEAT_MEMORY_PATH": str(memory_path),
},
clear=False,
)
cls.environment.start()
cls.service = SeatDemoService(REPOSITORY_ROOT)
cls.service.artifact_root = temporary_root / "artifacts"
cls.catalog = json.loads(CATALOG_PATH.read_text(encoding="utf-8"))
@classmethod
def tearDownClass(cls) -> None:
cls.environment.stop()
cls.temporary_directory.cleanup()
@staticmethod
def _debug_commands(result: dict) -> list[str]:
commands: list[str] = []
for plan in result.get("command_plans", {}).values():
for command in plan.get("spec", {}).get("commands", []):
debug_command = command.get("signals", {}).get("debug_command")
if isinstance(debug_command, str):
commands.append(debug_command)
return commands
@staticmethod
def _can0_operations(result: dict, expected: list[dict]) -> Counter[str]:
capabilities = {item["capability"] for item in expected}
operations: list[dict] = []
for config in result.get("function_configs", {}).values():
for operation in config.get("spec", {}).get("operations", []):
if operation.get("capability") not in capabilities:
continue
target = operation.get("target", {})
operations.append(
{
"target": target.get("instance", target.get("zone")),
"capability": operation["capability"],
"parameters": operation.get("parameters", {}),
}
)
return Counter(
json.dumps(item, ensure_ascii=False, sort_keys=True)
for item in operations
)
@staticmethod
def _expected_can0_operations(expected: list[dict]) -> Counter[str]:
return Counter(
json.dumps(item, ensure_ascii=False, sort_keys=True)
for item in expected
)
@staticmethod
def _single_passenger_motion_operation(result: dict) -> dict:
operations = [
operation
for config in result.get("function_configs", {}).values()
for operation in config.get("spec", {}).get("operations", [])
if operation.get("capability")
in {
"seat.set_longitudinal_position",
"seat.set_cushion_tilt",
"seat.set_backrest_recline",
"seat.set_legrest_height",
}
]
if len(operations) != 1:
raise AssertionError(f"expected one passenger motion operation: {operations}")
return operations[0]
def test_catalog_metadata_and_all_expanded_cases(self) -> None:
cases = self.catalog["cases"]
self.assertEqual(
self.catalog["schema"],
"natural-can-command-combination-suite/v1",
)
self.assertEqual(self.catalog["summary"]["total_cases"], len(cases))
self.assertGreaterEqual(len(cases), 1000)
self.assertEqual(len({item["id"] for item in cases}), len(cases))
for index, case in enumerate(cases):
with self.subTest(case=case["id"], utterance=case["utterance"]):
result = self.service.process(
case["utterance"],
provider="local",
conversation_id=f"conv-natural-combination-{index:04d}",
execution_mode="dry_run",
)
self.assertEqual(result["status"], case["expected_status"])
self.assertEqual(
self._debug_commands(result),
case["expected_commands"],
)
expected_can0 = case["expected_can0_operations"]
if expected_can0:
self.assertEqual(
self._can0_operations(result, expected_can0),
self._expected_can0_operations(expected_can0),
)
def test_can0_native_motion_and_dynamic_modes_combine_with_can1(self) -> None:
cases = (
(
"把副驾座椅水平位置调到35%,空调温度设置为24度,打开氛围灯",
["ac temp driver 24", "light ambient on"],
[{
"target": "passenger",
"capability": "seat.set_longitudinal_position",
"parameters": {"position_pct": 35},
}],
),
(
"副驾坐盆向上调整10%,关闭后排车窗,空调风量2档",
["window rear close", "ac fan 2"],
[{
"target": "passenger",
"capability": "seat.set_cushion_tilt",
"parameters": {"position_pct": 40},
}],
),
(
"开启木马模式并打开氛围灯",
["light ambient on"],
[{
"target": "passenger",
"capability": "seat.start_dynamic_mode",
"parameters": {"mode_id": 1},
}],
),
(
"开启动态摇篮模式,空调24度,打开遮阳帘",
["ac temp driver 24", "shade open"],
[{
"target": "passenger",
"capability": "seat.start_dynamic_mode",
"parameters": {"mode_id": 2},
}],
),
(
"副驾座椅前后调到20%、坐盆调到30%、靠背调到40%、腿托调到50%,空调风量3档,关闭全部车窗",
["ac fan 3", "window all close"],
[
{
"target": "passenger",
"capability": capability,
"parameters": {"position_pct": position},
}
for capability, position in (
("seat.set_longitudinal_position", 20),
("seat.set_cushion_tilt", 30),
("seat.set_backrest_recline", 40),
("seat.set_legrest_height", 50),
)
],
),
(
"副驾靠背向后移动15%,主驾温度设为25度,遮阳帘打开",
["ac temp driver 25", "shade open"],
[{
"target": "passenger",
"capability": "seat.set_backrest_recline",
"parameters": {"position_pct": 35},
}],
),
(
"副驾靠背往后转动10%,空调温度设置为24度",
["ac temp driver 24"],
[{
"target": "passenger",
"capability": "seat.set_backrest_recline",
"parameters": {"position_pct": 30},
}],
),
(
"副驾腿托设置为40%,关闭副驾车窗",
["window front-right close"],
[{
"target": "passenger",
"capability": "seat.set_legrest_height",
"parameters": {"position_pct": 40},
}],
),
)
for index, (utterance, can1_commands, can0_operations) in enumerate(cases):
with self.subTest(utterance=utterance):
result = self.service.process(
utterance,
provider="local",
conversation_id=f"conv-can0-can1-targeted-{index}",
execution_mode="dry_run",
)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(self._debug_commands(result), can1_commands)
self.assertEqual(
self._can0_operations(result, can0_operations),
self._expected_can0_operations(can0_operations),
)
def test_passenger_relative_and_absolute_directional_commands_stay_distinct(self) -> None:
cases = (
("副驾座椅往上移动10%", "basin", "decrease", "up", 10),
("副驾座椅往上移动20%", "basin", "decrease", "up", 20),
("副驾往下移动10%", "basin", "increase", "down", 10),
("副驾往下移动20%", "basin", "increase", "down", 20),
("副驾座椅往前移动10%", "horizontal", "decrease", "forward", 10),
("副驾座椅往前移动20%", "horizontal", "decrease", "forward", 20),
("副驾往后移动10%", "horizontal", "increase", "backward", 10),
("副驾往后移动20%", "horizontal", "increase", "backward", 20),
("副驾座椅靠背往前移动10%", "backrest", "decrease", "forward", 10),
("副驾座椅靠背往前移动30%", "backrest", "decrease", "forward", 30),
("副驾靠背往后移动10%", "backrest", "increase", "backward", 10),
("副驾靠背往后移动30%", "backrest", "increase", "backward", 30),
("副驾推托往上移动20%", "legrest", "increase", "up", 20),
("副驾推托往上移动40%", "legrest", "increase", "up", 40),
("副驾推托往下移动20%", "legrest", "decrease", "down", 20),
("副驾推托往下移动40%", "legrest", "decrease", "down", 40),
)
for index, (utterance, axis, direction, command_direction, amount) in enumerate(
cases
):
with self.subTest(kind="relative", utterance=utterance):
result = self.service.process(
utterance,
provider="local",
conversation_id=f"conv-passenger-relative-{index}",
execution_mode="dry_run",
)
self.assertEqual(result["status"], "succeeded")
operation = self._single_passenger_motion_operation(result)
motion = operation["motion"]
self.assertEqual(motion["mode"], "relative")
self.assertEqual(motion["direction"], direction)
self.assertEqual(
motion.get(
"requested_delta_position_pct",
motion["delta_position_pct"],
),
amount,
)
self.assertTrue(
MqttOtaSeatExecutor._operation_debug_command(operation).startswith(
f"seat passenger {axis} relative {command_direction} "
)
)
absolute_utterance = utterance.replace("移动", "移动到")
with self.subTest(kind="absolute", utterance=absolute_utterance):
result = self.service.process(
absolute_utterance,
provider="local",
conversation_id=f"conv-passenger-absolute-{index}",
execution_mode="dry_run",
)
self.assertEqual(result["status"], "succeeded")
operation = self._single_passenger_motion_operation(result)
self.assertNotIn("motion", operation)
self.assertEqual(operation["parameters"]["position_pct"], amount)
self.assertEqual(
MqttOtaSeatExecutor._operation_debug_command(operation),
f"seat passenger {axis} position {amount}",
)
if __name__ == "__main__":
unittest.main()