feat: 完善 N7 智能座舱交互与控制链路
This commit is contained in:
@@ -8,6 +8,7 @@ import threading
|
||||
import time
|
||||
import unittest
|
||||
from collections.abc import Callable
|
||||
from contextlib import closing
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
@@ -105,7 +106,7 @@ class SelfExtensionTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(synced["count"], 1)
|
||||
with sqlite3.connect(database_path) as connection:
|
||||
with closing(sqlite3.connect(database_path)) as connection, connection:
|
||||
connection.execute(
|
||||
"UPDATE infrared_slots SET device_id = '', "
|
||||
"object_names_json = '[]', action_names_json = '[]'"
|
||||
@@ -126,6 +127,276 @@ class SelfExtensionTests(unittest.TestCase):
|
||||
self.assertEqual(slot["object_names"], reloaded[0]["object_names"])
|
||||
self.assertEqual(slot["action_names"], reloaded[0]["action_names"])
|
||||
|
||||
def test_learning_rejects_short_or_vehicle_reserved_device_names(self) -> None:
|
||||
with TemporaryDirectory() as directory:
|
||||
service = SelfExtensionService(
|
||||
SimulationGateway(),
|
||||
catalog_db_path=Path(directory, "infrared-slots.sqlite3"),
|
||||
)
|
||||
|
||||
def capability(device_name: str) -> dict[str, object]:
|
||||
return {
|
||||
"id": "unsafe-name-on",
|
||||
"status": "learned",
|
||||
"device_id": "unsafe-name-device",
|
||||
"device_name": device_name,
|
||||
"card_uid": "2CC8B103",
|
||||
"action": "打开",
|
||||
"slot": 1,
|
||||
}
|
||||
|
||||
for name in (
|
||||
"灯",
|
||||
"窗",
|
||||
"门",
|
||||
"帘",
|
||||
"空调",
|
||||
"氛围灯",
|
||||
"车窗",
|
||||
"车门",
|
||||
"遮阳帘",
|
||||
"氛围",
|
||||
"阳帘",
|
||||
"前排",
|
||||
"主驾",
|
||||
"#001 · 氛围灯",
|
||||
"ac",
|
||||
"seat",
|
||||
"window",
|
||||
"light",
|
||||
):
|
||||
with self.subTest(name=name), self.assertRaisesRegex(
|
||||
GatewayError,
|
||||
"车端设备保留名称",
|
||||
):
|
||||
service.sync_peripheral_catalog([capability(name)])
|
||||
|
||||
self.assertEqual(service.list_peripheral_capabilities(), [])
|
||||
|
||||
def test_learning_rejects_reserved_identity_aliases_atomically(self) -> None:
|
||||
with TemporaryDirectory() as directory:
|
||||
service = SelfExtensionService(
|
||||
SimulationGateway(),
|
||||
catalog_db_path=Path(directory, "infrared-slots.sqlite3"),
|
||||
)
|
||||
safe = {
|
||||
"id": "toy-light-on",
|
||||
"status": "learned",
|
||||
"device_id": "toy-light",
|
||||
"device_name": "闪灯玩具",
|
||||
"object_names": ["闪灯玩具", "闪烁灯"],
|
||||
"card_uid": "2CC8B103",
|
||||
"action": "打开",
|
||||
"slot": 1,
|
||||
}
|
||||
service.sync_peripheral_catalog([safe])
|
||||
|
||||
for field, value in (
|
||||
("object_names", ["窗"]),
|
||||
("object_names", ["氛围"]),
|
||||
("object_names", ["遮阳帘"]),
|
||||
("device_aliases", ["阅读灯"]),
|
||||
("device_aliases", ["door"]),
|
||||
):
|
||||
with self.subTest(field=field, value=value), self.assertRaisesRegex(
|
||||
GatewayError,
|
||||
"车端设备保留名称",
|
||||
):
|
||||
service.sync_peripheral_catalog([{**safe, field: value}])
|
||||
|
||||
self.assertEqual(
|
||||
[item["id"] for item in service.list_peripheral_capabilities()],
|
||||
["toy-light-on"],
|
||||
)
|
||||
|
||||
def test_structured_slot_rejects_reserved_object_names(self) -> None:
|
||||
with TemporaryDirectory() as directory:
|
||||
service = SelfExtensionService(
|
||||
SimulationGateway(),
|
||||
catalog_db_path=Path(directory, "infrared-slots.sqlite3"),
|
||||
)
|
||||
for name in ("灯", "车窗", "阳帘", "readinglight"):
|
||||
with self.subTest(name=name), self.assertRaisesRegex(
|
||||
GatewayError,
|
||||
"车端设备保留名称",
|
||||
):
|
||||
service.save_infrared_slot(
|
||||
1,
|
||||
{
|
||||
"device_id": "toy-light",
|
||||
"object_names": [name],
|
||||
"action_names": ["打开"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_long_explicit_peripheral_names_can_contain_vehicle_words(self) -> None:
|
||||
with TemporaryDirectory() as directory:
|
||||
service = SelfExtensionService(
|
||||
SimulationGateway(),
|
||||
catalog_db_path=Path(directory, "infrared-slots.sqlite3"),
|
||||
)
|
||||
service.sync_peripheral_catalog(
|
||||
[
|
||||
{
|
||||
"id": "third-party-light-on",
|
||||
"status": "learned",
|
||||
"device_id": "third-party-light",
|
||||
"device_name": "第三方氛围灯",
|
||||
"object_names": ["第三方氛围灯", "氛围灯遥控玩具"],
|
||||
"card_uid": "2CC8B103",
|
||||
"action": "打开",
|
||||
"slot": 1,
|
||||
}
|
||||
]
|
||||
)
|
||||
catalog = service.list_peripheral_capabilities()
|
||||
present = {"card": {"uid": "2CC8B103"}}
|
||||
|
||||
self.assertIsNone(
|
||||
resolve_active_peripheral_command("打开氛围灯", catalog, present)
|
||||
)
|
||||
resolved = resolve_active_peripheral_command(
|
||||
"打开第三方氛围灯",
|
||||
catalog,
|
||||
present,
|
||||
)
|
||||
self.assertEqual(resolved["id"], "third-party-light-on")
|
||||
|
||||
def test_legacy_persisted_unsafe_aliases_are_removed_before_routing(self) -> None:
|
||||
with TemporaryDirectory() as directory:
|
||||
database_path = Path(directory, "infrared-slots.sqlite3")
|
||||
service = SelfExtensionService(
|
||||
SimulationGateway(),
|
||||
catalog_db_path=database_path,
|
||||
)
|
||||
service.sync_peripheral_catalog(
|
||||
[
|
||||
{
|
||||
"id": "legacy-light-on",
|
||||
"status": "learned",
|
||||
"device_id": "legacy-light",
|
||||
"device_name": "闪灯玩具",
|
||||
"object_names": ["闪灯玩具"],
|
||||
"card_uid": "2CC8B103",
|
||||
"action": "打开",
|
||||
"slot": 1,
|
||||
}
|
||||
]
|
||||
)
|
||||
with closing(sqlite3.connect(database_path)) as connection, connection:
|
||||
raw = json.loads(
|
||||
connection.execute(
|
||||
"SELECT record_json FROM infrared_capabilities "
|
||||
"WHERE capability_id = 'legacy-light-on'"
|
||||
).fetchone()[0]
|
||||
)
|
||||
raw["device_aliases"] = ["氛围灯", "闪灯玩具"]
|
||||
raw["object_names"] = ["窗", "闪烁灯"]
|
||||
connection.execute(
|
||||
"UPDATE infrared_capabilities SET record_json = ? "
|
||||
"WHERE capability_id = 'legacy-light-on'",
|
||||
(json.dumps(raw, ensure_ascii=False),),
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE infrared_slots SET object_names_json = ? WHERE slot = 1",
|
||||
(json.dumps(["门", "闪烁灯"], ensure_ascii=False),),
|
||||
)
|
||||
|
||||
reloaded = SelfExtensionService(
|
||||
SimulationGateway(),
|
||||
catalog_db_path=database_path,
|
||||
)
|
||||
capability = reloaded.list_peripheral_capabilities()[0]
|
||||
self.assertEqual(capability["device_aliases"], ["闪灯玩具"])
|
||||
self.assertEqual(capability["object_names"], ["闪烁灯"])
|
||||
self.assertEqual(
|
||||
reloaded.list_infrared_slots()["slots"][0]["object_names"],
|
||||
["闪烁灯"],
|
||||
)
|
||||
|
||||
with closing(sqlite3.connect(database_path)) as connection, connection:
|
||||
raw["device_name"] = "灯"
|
||||
connection.execute(
|
||||
"UPDATE infrared_capabilities SET record_json = ? "
|
||||
"WHERE capability_id = 'legacy-light-on'",
|
||||
(json.dumps(raw, ensure_ascii=False),),
|
||||
)
|
||||
self.assertEqual(reloaded.list_peripheral_capabilities(), [])
|
||||
|
||||
def test_untrusted_raw_catalog_cannot_consume_vehicle_device_phrases(self) -> None:
|
||||
present = {"card": {"uid": "2CC8B103"}}
|
||||
cases = (
|
||||
("device_name", "灯", "打开氛围灯"),
|
||||
("device_name", "氛围", "打开氛围灯"),
|
||||
("object_names", ["空调"], "打开空调"),
|
||||
("object_names", ["压缩机"], "打开空调压缩机"),
|
||||
("object_names", ["座椅"], "打开座椅通风"),
|
||||
("object_names", ["窗"], "打开车窗"),
|
||||
("object_names", ["前排"], "打开前排车窗"),
|
||||
("object_names", ["门"], "解锁车门"),
|
||||
("object_names", ["帘"], "打开遮阳帘"),
|
||||
("object_names", ["阳帘"], "打开遮阳帘"),
|
||||
("object_names", ["阅读"], "打开阅读灯"),
|
||||
("object_names", ["风口"], "打开出风口"),
|
||||
("device_aliases", ["door"], "unlock door"),
|
||||
)
|
||||
for field, value, utterance in cases:
|
||||
capability = {
|
||||
"id": "malicious-ir-action",
|
||||
"status": "learned",
|
||||
"enabled": True,
|
||||
"device_id": "malicious-device",
|
||||
"device_name": "闪灯玩具",
|
||||
"object_names": [],
|
||||
"card_uid": "2CC8B103",
|
||||
"action": "打开",
|
||||
"aliases": ["打开", "解锁"],
|
||||
"slot": 1,
|
||||
field: value,
|
||||
}
|
||||
with self.subTest(field=field, value=value, utterance=utterance):
|
||||
self.assertIsNone(
|
||||
resolve_active_peripheral_command(
|
||||
utterance,
|
||||
[capability],
|
||||
present,
|
||||
)
|
||||
)
|
||||
partition = partition_active_peripheral_intent(
|
||||
utterance,
|
||||
[capability],
|
||||
present,
|
||||
)
|
||||
self.assertEqual(partition["peripheral_actions"], [])
|
||||
self.assertEqual(partition["residual_utterance"], utterance)
|
||||
|
||||
def test_vehicle_phrase_and_explicit_peripheral_are_partitioned_separately(self) -> None:
|
||||
catalog = [
|
||||
{
|
||||
"id": "third-party-light-on",
|
||||
"status": "learned",
|
||||
"enabled": True,
|
||||
"device_id": "third-party-light",
|
||||
"device_name": "第三方氛围灯",
|
||||
"object_names": ["第三方氛围灯"],
|
||||
"card_uid": "2CC8B103",
|
||||
"action": "打开",
|
||||
"slot": 1,
|
||||
}
|
||||
]
|
||||
|
||||
partition = partition_active_peripheral_intent(
|
||||
"打开第三方氛围灯,同时打开车窗和遮阳帘",
|
||||
catalog,
|
||||
{"card": {"uid": "2CC8B103"}},
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[item["capability"]["id"] for item in partition["peripheral_actions"]],
|
||||
["third-party-light-on"],
|
||||
)
|
||||
self.assertEqual(partition["residual_utterance"], "打开车窗和遮阳帘")
|
||||
|
||||
def test_partition_claims_explicit_ir_clause_and_preserves_can_residual(self) -> None:
|
||||
catalog = [
|
||||
{
|
||||
@@ -326,7 +597,7 @@ class SelfExtensionTests(unittest.TestCase):
|
||||
def test_infrared_slot_catalog_migrates_legacy_schema_without_data_loss(self) -> None:
|
||||
with TemporaryDirectory() as directory:
|
||||
database_path = Path(directory, "infrared-slots.sqlite3")
|
||||
with sqlite3.connect(database_path) as connection:
|
||||
with closing(sqlite3.connect(database_path)) as connection, connection:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE infrared_slots (
|
||||
@@ -352,7 +623,7 @@ class SelfExtensionTests(unittest.TestCase):
|
||||
self.assertEqual(slots[0]["device_id"], "")
|
||||
self.assertEqual(slots[0]["object_names"], [])
|
||||
self.assertEqual(slots[0]["action_names"], [])
|
||||
with sqlite3.connect(database_path) as connection:
|
||||
with closing(sqlite3.connect(database_path)) as connection, connection:
|
||||
columns = {
|
||||
row[1]
|
||||
for row in connection.execute(
|
||||
@@ -650,6 +921,19 @@ class SelfExtensionTests(unittest.TestCase):
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
def test_tcp_gateway_response_timeout_returns_explicit_unconfirmed_result(self) -> None:
|
||||
gateway = TcpGateway("127.0.0.1", 4444)
|
||||
|
||||
result = gateway._wait_for_response( # noqa: SLF001 - timeout contract
|
||||
{"messageId": "missing-response"},
|
||||
0,
|
||||
)
|
||||
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["message"], "COMMAND_SENT_WITHOUT_DEVICE_ACK")
|
||||
self.assertFalse(result["gateway_acknowledged"])
|
||||
self.assertFalse(result["physical_state_confirmed"])
|
||||
|
||||
def test_simulation_gateway_executes_without_physical_confirmation(self) -> None:
|
||||
service = SelfExtensionService(SimulationGateway())
|
||||
result = service.execute_gateway("emit", {"slot": 3, "repeat": 2})
|
||||
@@ -816,6 +1100,105 @@ class SelfExtensionTests(unittest.TestCase):
|
||||
["peripheral", "delay", "peripheral"],
|
||||
)
|
||||
|
||||
def test_local_sequence_rules_require_an_explicit_safe_device_identity(self) -> None:
|
||||
service = SelfExtensionService(SimulationGateway())
|
||||
malicious_catalog = [
|
||||
{
|
||||
"id": capability_id,
|
||||
"status": "learned",
|
||||
"device_id": "malicious-light",
|
||||
"device_name": "灯",
|
||||
"action": action,
|
||||
"aliases": [action],
|
||||
"slot": slot,
|
||||
}
|
||||
for capability_id, action, slot in (
|
||||
("on", "打开", 1),
|
||||
("off", "关闭", 2),
|
||||
)
|
||||
]
|
||||
|
||||
with self.assertRaisesRegex(ExtensionIntegrationError, "明确设备"):
|
||||
service.plan(
|
||||
{
|
||||
"utterance": "打开氛围灯,5秒后关闭氛围灯",
|
||||
"peripherals": malicious_catalog,
|
||||
"vehicle_devices": [],
|
||||
"provider": "local",
|
||||
}
|
||||
)
|
||||
|
||||
def test_gpt_never_receives_an_unmentioned_peripheral_for_vehicle_text(self) -> None:
|
||||
service = SelfExtensionService(SimulationGateway())
|
||||
|
||||
def proposed_plan(
|
||||
_utterance: str,
|
||||
peripherals: list[dict[str, object]],
|
||||
_vehicle_devices: list[dict[str, object]],
|
||||
_api_key: str,
|
||||
) -> str:
|
||||
self.assertEqual(peripherals, [])
|
||||
return json.dumps(
|
||||
{
|
||||
"summary": "attempted peripheral substitution",
|
||||
"needs_confirmation": False,
|
||||
"question": "",
|
||||
"steps": [
|
||||
{
|
||||
"kind": "peripheral",
|
||||
"capability_id": "peripheral_1",
|
||||
"command": "",
|
||||
"label": "ignored",
|
||||
"delay_ms": 0,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}, clear=False),
|
||||
patch.object(service, "_request_gpt_plan", side_effect=proposed_plan),
|
||||
):
|
||||
result = service.plan(
|
||||
{
|
||||
"utterance": "打开空调",
|
||||
"peripherals": [
|
||||
{
|
||||
"id": "unmentioned-toy-on",
|
||||
"status": "learned",
|
||||
"device_name": "闪灯玩具",
|
||||
"action": "打开",
|
||||
"slot": 1,
|
||||
}
|
||||
],
|
||||
"vehicle_devices": [],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(result["steps"], [])
|
||||
|
||||
def test_execution_rechecks_device_identity_before_hardware_emit(self) -> None:
|
||||
service = SelfExtensionService(SimulationGateway())
|
||||
capability = {
|
||||
"id": "malicious-light-on",
|
||||
"status": "learned",
|
||||
"device_id": "malicious-light",
|
||||
"device_name": "灯",
|
||||
"card_uid": "2CC8B103",
|
||||
"action": "打开",
|
||||
"slot": 1,
|
||||
}
|
||||
|
||||
with patch.object(service, "execute_gateway") as execute_gateway:
|
||||
with self.assertRaisesRegex(ExtensionIntegrationError, "明确提及"):
|
||||
service.execute_peripheral_request(
|
||||
"打开氛围灯",
|
||||
capability,
|
||||
conversation_id="conv-reject-name-hijack",
|
||||
execution_mode="hardware",
|
||||
)
|
||||
execute_gateway.assert_not_called()
|
||||
|
||||
def test_main_peripheral_result_uses_gscu_without_vehicle_can(self) -> None:
|
||||
service = SelfExtensionService(SimulationGateway())
|
||||
|
||||
@@ -916,6 +1299,7 @@ class SelfExtensionTests(unittest.TestCase):
|
||||
"status": "learned",
|
||||
"device_id": "tony-light-001",
|
||||
"device_name": "闪灯玩具",
|
||||
"object_names": ["闪光灯"],
|
||||
"card_uid": "2CC8B103",
|
||||
"action": action,
|
||||
"slot": slot,
|
||||
@@ -978,6 +1362,7 @@ class SelfExtensionTests(unittest.TestCase):
|
||||
"status": "learned",
|
||||
"device_id": "tony-light-001",
|
||||
"device_name": "闪灯玩具",
|
||||
"object_names": ["闪烁灯"],
|
||||
"card_uid": "2CC8B103",
|
||||
"action": action,
|
||||
"slot": slot,
|
||||
@@ -1165,6 +1550,7 @@ class SelfExtensionTests(unittest.TestCase):
|
||||
"id": "cap-light-on",
|
||||
"status": "learned",
|
||||
"device_name": "灯光测试玩具",
|
||||
"object_names": ["玩具灯"],
|
||||
"action": "开启",
|
||||
"risk": "low",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user