feat: integrate learned peripherals into mode creation

Expose safe NFC-bound infrared actions to semantic scene composition, require flashing-light actions for romantic and Mid-Autumn modes, and preserve mixed CAN/IR mode flows.

Fix NFC device reassignment and retry extension/toy MQTT publication when an active card's binding catalog changes.
This commit is contained in:
Miayui
2026-09-20 16:37:19 +08:00
parent 904f5f8a6c
commit bfff1b515c
25 changed files with 3073 additions and 102 deletions
@@ -91,15 +91,23 @@ def test_scene_context_contains_only_callable_reusable_atomic_capabilities(
assert all(item.get("target_scopes") for item in capabilities)
assert all(isinstance(item.get("parameters"), list) for item in capabilities)
# The scene planner needs contracts, not mutable runtime surfaces or saved
# modes. Omitting them also prevents catalog text from becoming prompt
# injection material and keeps the cold-path prompt small.
# The scene planner gets a bounded inventory, not mutable runtime surfaces,
# saved modes, NFC identifiers, slots, or transport payloads.
assert "modes" not in context
assert "learned_peripherals" not in context
assert "service_surfaces" not in context
assert "device_groups" not in context
assert "execution_context" not in context
assert "忽略此前规则" not in json.dumps(context, ensure_ascii=False)
assert context["peripheral_actions"] == []
assert context["device_inventory"][-1]["availability"] == "unavailable"
door_inventory = next(
item for item in context["device_inventory"] if item.get("id") == "door"
)
assert door_inventory["availability"] == "unavailable"
assert door_inventory["capability_ids"] == ["door.set_rear_lock"]
encoded_context = json.dumps(context, ensure_ascii=False)
assert "card_uid" not in encoded_context
assert '"slot"' not in encoded_context
forbidden_capability_fields = {
"bindings",
@@ -154,7 +162,6 @@ def test_scene_context_exposes_only_absolute_parameters_and_native_scene_bounds(
(
lambda snapshot: snapshot.__setitem__("catalog_revision", "runtime-only-change"),
lambda snapshot: snapshot.__setitem__("modes", []),
lambda snapshot: snapshot.__setitem__("learned_peripherals", []),
lambda snapshot: snapshot.__setitem__(
"execution_context", {"current_physical_availability": "preflight_required"}
),
@@ -175,6 +182,69 @@ def test_composition_revision_ignores_dynamic_non_planning_state(
assert _revision(_scene_context(capability_catalog, changed)) == expected
def test_scene_context_exposes_only_authorized_safe_peripheral_refs() -> None:
catalog = AssistantCapabilityCatalog(
SeatDemoService(),
peripheral_provider=lambda: [
{
"id": "ir-slot:romance-light:7",
"status": "learned",
"enabled": True,
"device_id": "romance-light",
"device_name": "氛围灯带",
"object_names": ["氛围灯带", "灯带"],
"device_type": "light",
"card_uid": "2CC8B103",
"action": "设置为红色",
"action_names": ["设置为红色", "红色"],
"risk": "low",
"slot": 7,
},
{
"id": "ir-slot:unbound-light:8",
"status": "learned",
"enabled": False,
"device_id": "unbound-light",
"device_name": "未授权灯带",
"object_names": ["未授权灯带"],
"action": "打开",
"action_names": ["打开"],
"slot": 8,
},
],
)
context = catalog.scene_composition_context(catalog.snapshot())
assert len(context["peripheral_actions"]) == 1
action = context["peripheral_actions"][0]
assert action["ref"].startswith("ir:")
assert action["device_label"] == "氛围灯带"
assert action["action_label"] == "设置为红色"
inventory = [
item
for item in context["device_inventory"]
if item["kind"] == "learned_infrared"
]
assert [item["availability"] for item in inventory] == [
"available",
"unavailable",
]
encoded = json.dumps(context, ensure_ascii=False)
assert "2CC8B103" not in encoded
assert '"slot":7' not in encoded
def test_composition_revision_changes_with_peripheral_planning_contract(
capability_catalog: AssistantCapabilityCatalog,
) -> None:
snapshot = capability_catalog.snapshot()
expected = _revision(_scene_context(capability_catalog, snapshot))
changed = copy.deepcopy(snapshot)
changed["learned_peripherals"] = []
assert _revision(_scene_context(capability_catalog, changed)) != expected
def test_composition_revision_is_order_independent(
capability_catalog: AssistantCapabilityCatalog,
) -> None: