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
+389 -3
View File
@@ -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",
}