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:
@@ -27,7 +27,7 @@ def test_console_contains_ai_assistant_surface():
|
||||
def test_codex_assistant_assets_are_versioned_together_in_the_service_worker():
|
||||
service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8")
|
||||
assert (
|
||||
'const CACHE_VERSION = "cognitive-agent-pad-v324-firmware-notification"'
|
||||
'const CACHE_VERSION = "cognitive-agent-pad-v328-nfc-mqtt"'
|
||||
in service_worker
|
||||
)
|
||||
assert '"/assistant-chat.css?v=codex-assistant-29"' in service_worker
|
||||
|
||||
@@ -41,7 +41,7 @@ def test_energy_monitor_assets_are_versioned_and_cached() -> None:
|
||||
"/energy-monitor.js?v=energy-monitor-5-performance"
|
||||
)
|
||||
assert (
|
||||
'const CACHE_VERSION = "cognitive-agent-pad-v324-firmware-notification"'
|
||||
'const CACHE_VERSION = "cognitive-agent-pad-v328-nfc-mqtt"'
|
||||
in service_worker
|
||||
)
|
||||
|
||||
|
||||
@@ -174,6 +174,57 @@ def test_no_matching_ability_publishes_nothing() -> None:
|
||||
service.close()
|
||||
|
||||
|
||||
def test_binding_a_still_present_card_retries_after_catalog_revision_change() -> None:
|
||||
nfc = {"card": {"action": "present", "uid": "E113B203"}}
|
||||
catalog: list[dict[str, Any]] = []
|
||||
catalog_revision = {"value": "catalog-a"}
|
||||
publications: list[dict[str, Any]] = []
|
||||
service = NfcExtensionToyPublisher(
|
||||
nfc_provider=lambda: nfc,
|
||||
catalog_provider=lambda: catalog,
|
||||
catalog_revision_provider=lambda: catalog_revision["value"],
|
||||
publisher=lambda payload: publications.append(dict(payload))
|
||||
or {"delivery_status": "queued", "message_id": len(publications)},
|
||||
burst_window_s=0.06,
|
||||
poll_interval_s=0.005,
|
||||
)
|
||||
service.start()
|
||||
try:
|
||||
assert wait_until(
|
||||
lambda: service.snapshot()["state"] == "no_matching_extension"
|
||||
)
|
||||
assert publications == []
|
||||
|
||||
catalog.append(capability("new-device-on"))
|
||||
catalog_revision["value"] = "catalog-b"
|
||||
|
||||
assert wait_until(lambda: len(publications) == 3)
|
||||
status = service.snapshot()
|
||||
assert status["state"] == "burst_complete"
|
||||
assert status["topic"] == "extension/toy"
|
||||
assert status["active_catalog_revision"] == "catalog-b"
|
||||
assert status["burst_published_count"] == 3
|
||||
|
||||
catalog[:] = [
|
||||
capability(
|
||||
"replacement-device-on",
|
||||
device_id="toy-light-003",
|
||||
device_name="新灯带",
|
||||
slot=21,
|
||||
)
|
||||
]
|
||||
catalog_revision["value"] = "catalog-c"
|
||||
assert wait_until(lambda: len(publications) == 6)
|
||||
assert publications[-1]["actions"][0]["target_scope"] == "toy-light-003"
|
||||
|
||||
# An unrelated revision with identical abilities must not duplicate a burst.
|
||||
catalog_revision["value"] = "catalog-d"
|
||||
time.sleep(0.08)
|
||||
assert len(publications) == 6
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
|
||||
def test_failed_burst_attempts_only_three_frames_until_redetected() -> None:
|
||||
nfc: dict[str, Any] = {"card": {"action": "present", "uid": "E113B203"}}
|
||||
attempts: list[float] = []
|
||||
@@ -216,6 +267,7 @@ def test_server_wires_publisher_without_invoking_infrared_execution() -> None:
|
||||
|
||||
assert "extension_toys = NfcExtensionToyPublisher(" in server
|
||||
assert "catalog_provider=extension.list_peripheral_capabilities" in server
|
||||
assert "catalog_revision_provider=extension.peripheral_catalog_revision" in server
|
||||
assert "publisher=service.publish_extension_toy_message" in server
|
||||
assert "SeatDemoHandler.extension_toys.start()" in server
|
||||
assert "SeatDemoHandler.extension_toys.close()" in server
|
||||
|
||||
@@ -94,10 +94,10 @@ def test_performance_assets_have_a_new_cache_generation() -> None:
|
||||
for asset in (
|
||||
"/app.js?v=dual-seat-145-n7-dispatch",
|
||||
"/console-v2.js?v=local-first-77-performance",
|
||||
"/self-extension.js?v=pet-settings-53-performance",
|
||||
"/self-extension.js?v=nfc-mqtt-56",
|
||||
"/energy-monitor.js?v=energy-monitor-5-performance",
|
||||
):
|
||||
assert asset in html
|
||||
assert f'"{asset}"' in worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v324-firmware-notification"' in worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v328-nfc-mqtt"' in worker
|
||||
assert '"/codepen-glass-theme.css?v=creative-cloud-6-performance"' in worker
|
||||
|
||||
@@ -54,7 +54,7 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None:
|
||||
assert source_asset in main
|
||||
assert f'"{cached_asset}"' in service_worker
|
||||
assert 'import "./styles/design-system.css"' in main
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v324-firmware-notification"' in service_worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v328-nfc-mqtt"' in service_worker
|
||||
|
||||
|
||||
def test_reference_glass_theme_covers_the_complete_workspace() -> None:
|
||||
|
||||
@@ -21,6 +21,11 @@ def test_unified_console_exposes_device_id_based_nfc_authorization() -> None:
|
||||
assert '<h2>NFC 设备授权</h2>' in script
|
||||
assert 'NFC 设备授权' in script
|
||||
assert 'api("nfc/status")' in script
|
||||
assert 'function applyExtensionToySnapshot(value' in script
|
||||
assert 'value.extension_toy' in script
|
||||
assert 'topic: "extension/toy"' in script
|
||||
assert '发送失败:${extensionToySnapshot.last_error' in script
|
||||
assert 'burst_published_count' in script
|
||||
assert 'function bindCardToDevice(device, cardUid)' in script
|
||||
assert 'function infraredDeviceGroups()' in script
|
||||
assert 'function configuredCapabilities(deviceId = null)' in script
|
||||
@@ -28,6 +33,10 @@ def test_unified_console_exposes_device_id_based_nfc_authorization() -> None:
|
||||
assert 'data-nfc-capability-enabled' in script
|
||||
assert 'data-nfc-select-all' in script
|
||||
assert '全部命令已默认选中' in script
|
||||
assert 'class="v2-nfc-existing v2-nfc-rebind"' in script
|
||||
assert '将当前 NFC 改绑到新设备 ID' in script
|
||||
assert '改绑设备 ID' in script
|
||||
assert '旧设备 ID 的 NFC 授权会解除' in script
|
||||
assert 'function createToyFromNfc()' not in script
|
||||
assert 'data-nfc-learn-capability' not in script
|
||||
assert 'data-nfc-relearn-capability' not in script
|
||||
@@ -38,6 +47,7 @@ def test_unified_console_exposes_device_id_based_nfc_authorization() -> None:
|
||||
assert 'value.legacy_profiles' in script
|
||||
assert 'value.legacy_bridge_addresses' in script
|
||||
assert 'device.cardUid = normalizedUid' in script
|
||||
assert 'item.cardUid = null' in script
|
||||
assert 'const targetSlot = relearning ? Number(capability.slot) : allocateSlot()' in script
|
||||
assert 'sendGatewayCommand("read", { slot: learning.draft.slot })' in script
|
||||
assert 'function infraredSignalFromResponse(value)' in script
|
||||
@@ -61,7 +71,7 @@ def test_unified_console_exposes_device_id_based_nfc_authorization() -> None:
|
||||
assert '.v2-nfc-command-list' in styles
|
||||
assert '.v2-nfc-command-option' in styles
|
||||
assert '.v2-nfc-select-all' in styles
|
||||
assert 'self-extension.js?v=pet-settings-53-performance' in html
|
||||
assert 'self-extension.js?v=nfc-mqtt-56' in html
|
||||
assert 'import "../self-extension.css";' in vite_entry
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -15,6 +15,7 @@ from cognitive_agent.application.codex_assistant import (
|
||||
CodexAssistantService,
|
||||
_mode_profile_create_parts,
|
||||
)
|
||||
from cognitive_agent.application.scene_composer import scene_semantic_guidance
|
||||
|
||||
CONTROL_TERMS = ("空调", "座椅", "车窗", "遮阳帘", "氛围灯", "阅读灯", "出风口")
|
||||
|
||||
@@ -488,6 +489,213 @@ def test_exact_scene_template_hit_skips_codex_and_previews_a_fresh_plan() -> Non
|
||||
assert events[0]["engine"] == "n7web-scene-template"
|
||||
|
||||
|
||||
def test_exact_scene_template_keeps_native_base_and_adds_only_selected_ir_ref() -> None:
|
||||
client = StructuredSceneClient()
|
||||
capability_ref = "ir:0123456789abcdef01234567"
|
||||
client.structured_answers.put(
|
||||
json.dumps(
|
||||
{
|
||||
"requests": [{"capability_ref": capability_ref}],
|
||||
"ambiguities": [],
|
||||
"confidence": 0.93,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
assistant = CodexAssistantService(client)
|
||||
template = _scene_template()
|
||||
context = _scene_context()
|
||||
context["peripheral_actions"] = [
|
||||
{
|
||||
"ref": capability_ref,
|
||||
"device_label": "氛围灯带",
|
||||
"action_label": "设置为红色",
|
||||
"semantic_tags": ["浪漫", "红色", "灯光"],
|
||||
}
|
||||
]
|
||||
context["device_inventory"] = [
|
||||
{
|
||||
"kind": "learned_infrared",
|
||||
"id": "romance-light",
|
||||
"label": "氛围灯带",
|
||||
"action": "设置为红色",
|
||||
"availability": "available",
|
||||
}
|
||||
]
|
||||
observed: list[dict[str, object]] = []
|
||||
|
||||
def execute(
|
||||
_instruction: str,
|
||||
_progress: Callable[[dict[str, object]], None],
|
||||
assistant_plan: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
observed.append(deepcopy(dict(assistant_plan)))
|
||||
return _preview_result("观影模式")
|
||||
|
||||
result, _events = _invoke(
|
||||
assistant,
|
||||
"构建一个观影模式",
|
||||
execute,
|
||||
conversation_id="conv-scene-template-ir-augmentation",
|
||||
catalog=_catalog_with_templates(template),
|
||||
scene_context=context,
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["route"] == "scene_template"
|
||||
assert len(client.structured_calls) == 1
|
||||
assert not client.legacy_calls
|
||||
assert observed[0]["requests"][:2] == template["plan"]["requests"]
|
||||
assert observed[0]["requests"][2] == {
|
||||
"domain": "self_extension",
|
||||
"capability": "infrared.emit",
|
||||
"target_scope": capability_ref,
|
||||
"operation": "emit",
|
||||
"duration_ms": None,
|
||||
"parameters": [{"name": "capability_ref", "value": capability_ref}],
|
||||
"reason": (
|
||||
"Scene Composer selected a server-issued learned-device reference; "
|
||||
"runtime binding resolution is still required."
|
||||
),
|
||||
}
|
||||
assert "learned-device augmentation" in client.thread_instructions[0]
|
||||
|
||||
|
||||
def _scene_context_with_flashing_light() -> dict[str, object]:
|
||||
context = _scene_context()
|
||||
context["peripheral_actions"] = [
|
||||
{
|
||||
"ref": "ir:flash-open-000000000001",
|
||||
"device_label": "闪烁灯",
|
||||
"object_names": ["闪烁灯", "闪光灯"],
|
||||
"action_label": "打开",
|
||||
"action_names": ["打开", "开启", "启动"],
|
||||
"semantic_tags": ["闪烁灯", "灯光"],
|
||||
},
|
||||
{
|
||||
"ref": "ir:flash-red-0000000000002",
|
||||
"device_label": "闪烁灯",
|
||||
"object_names": ["闪烁灯", "闪光灯"],
|
||||
"action_label": "切换为红色",
|
||||
"action_names": ["切换为红色", "设置为红色"],
|
||||
"semantic_tags": ["闪烁灯", "红色", "灯光"],
|
||||
},
|
||||
{
|
||||
"ref": "ir:flash-close-00000000003",
|
||||
"device_label": "闪烁灯",
|
||||
"object_names": ["闪烁灯", "闪光灯"],
|
||||
"action_label": "关闭",
|
||||
"action_names": ["关闭", "熄灭"],
|
||||
"semantic_tags": ["闪烁灯", "灯光"],
|
||||
},
|
||||
]
|
||||
context["device_inventory"] = [
|
||||
{
|
||||
"kind": "learned_infrared",
|
||||
"id": "toy-light-001",
|
||||
"label": "闪烁灯",
|
||||
"availability": "available",
|
||||
}
|
||||
]
|
||||
return context
|
||||
|
||||
|
||||
def _peripheral_refs(plan: Mapping[str, object]) -> list[str]:
|
||||
return [
|
||||
str(parameter.get("value") or "")
|
||||
for request in plan.get("requests", [])
|
||||
if isinstance(request, Mapping)
|
||||
and request.get("domain") == "self_extension"
|
||||
for parameter in request.get("parameters", [])
|
||||
if isinstance(parameter, Mapping)
|
||||
and parameter.get("name") == "capability_ref"
|
||||
]
|
||||
|
||||
|
||||
def test_romantic_template_always_adds_authorized_flashing_light() -> None:
|
||||
client = StructuredSceneClient()
|
||||
client.structured_answers.put(
|
||||
json.dumps(
|
||||
{"requests": [], "ambiguities": [], "confidence": 0.82},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
assistant = CodexAssistantService(client)
|
||||
template = _scene_template(
|
||||
template_id="romantic",
|
||||
name="浪漫模式",
|
||||
aliases=["约会模式"],
|
||||
)
|
||||
observed: list[dict[str, object]] = []
|
||||
|
||||
def execute(
|
||||
_instruction: str,
|
||||
_progress: Callable[[dict[str, object]], None],
|
||||
assistant_plan: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
observed.append(deepcopy(dict(assistant_plan)))
|
||||
return _preview_result("浪漫模式")
|
||||
|
||||
result, _events = _invoke(
|
||||
assistant,
|
||||
"创建浪漫模式",
|
||||
execute,
|
||||
conversation_id="conv-romantic-required-flashing-light",
|
||||
catalog=_catalog_with_templates(template),
|
||||
scene_context=_scene_context_with_flashing_light(),
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert observed[0]["requests"][:2] == template["plan"]["requests"]
|
||||
assert _peripheral_refs(observed[0]) == [
|
||||
"ir:flash-open-000000000001",
|
||||
"ir:flash-red-0000000000002",
|
||||
]
|
||||
assert "ir:flash-close-00000000003" not in _peripheral_refs(observed[0])
|
||||
|
||||
|
||||
def test_mid_autumn_cloud_composition_receives_all_abilities_and_adds_flashing_light() -> None:
|
||||
client = StructuredSceneClient()
|
||||
assistant = CodexAssistantService(client)
|
||||
observed: list[dict[str, object]] = []
|
||||
scene_context = _scene_context_with_flashing_light()
|
||||
|
||||
def execute(
|
||||
_instruction: str,
|
||||
_progress: Callable[[dict[str, object]], None],
|
||||
assistant_plan: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
observed.append(deepcopy(dict(assistant_plan)))
|
||||
return _preview_result("中秋欢快模式")
|
||||
|
||||
result, _events = _invoke(
|
||||
assistant,
|
||||
"创建中秋欢快模式",
|
||||
execute,
|
||||
conversation_id="conv-mid-autumn-cloud-peripherals",
|
||||
catalog=_catalog_with_templates(_scene_template()),
|
||||
scene_context=scene_context,
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["route"] == "scene_composer"
|
||||
call_context = client.structured_calls[0]["application_context"]
|
||||
assert len(call_context["capabilities"]) == len(scene_context["capabilities"])
|
||||
assert len(call_context["peripheral_actions"]) == 3
|
||||
assert call_context["scene_semantics"]["family"] == "festival_celebration"
|
||||
assert call_context["scene_semantics"]["peripheral_policy"][
|
||||
"required_refs"
|
||||
] == [
|
||||
"ir:flash-open-000000000001",
|
||||
"ir:flash-red-0000000000002",
|
||||
]
|
||||
assert "as many relevant" in client.thread_instructions[0]
|
||||
assert _peripheral_refs(observed[0]) == [
|
||||
"ir:flash-open-000000000001",
|
||||
"ir:flash-red-0000000000002",
|
||||
]
|
||||
|
||||
|
||||
def test_scene_template_miss_falls_through_to_structured_scene_composer() -> None:
|
||||
client = StructuredSceneClient()
|
||||
assistant = CodexAssistantService(client)
|
||||
@@ -517,6 +725,44 @@ def test_scene_template_miss_falls_through_to_structured_scene_composer() -> Non
|
||||
assert events[0]["engine"] == "codex-app-server-scene-composer"
|
||||
|
||||
|
||||
def test_learning_mode_uses_grounded_free_composition_without_device_names() -> None:
|
||||
client = StructuredSceneClient()
|
||||
assistant = CodexAssistantService(client)
|
||||
observed: list[dict[str, object]] = []
|
||||
|
||||
def execute(
|
||||
instruction: str,
|
||||
_progress: Callable[[dict[str, object]], None],
|
||||
assistant_plan: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
assert instruction == "创建学习模式"
|
||||
observed.append(deepcopy(dict(assistant_plan)))
|
||||
return _preview_result("学习模式")
|
||||
|
||||
result, _events = _invoke(
|
||||
assistant,
|
||||
"创建学习模式",
|
||||
execute,
|
||||
conversation_id="conv-learning-mode-free-composition",
|
||||
catalog=_catalog_with_templates(_scene_template()),
|
||||
scene_context=_scene_context(),
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["route"] == "scene_composer"
|
||||
assert len(client.structured_calls) == 1
|
||||
context = client.structured_calls[0]["application_context"]
|
||||
semantics = context["scene_semantics"]
|
||||
assert semantics["family"] == "focused_learning"
|
||||
assert semantics["grounded"] is True
|
||||
assert "climate.set_temperature" in semantics["preferred_capability_ids"]
|
||||
output_schema = client.structured_calls[0]["output_schema"]
|
||||
assert output_schema["properties"]["requests"]["minItems"] == 1
|
||||
assert output_schema["properties"]["ambiguities"]["maxItems"] == 0
|
||||
assert "学习/自习/专注" in client.thread_instructions[0]
|
||||
assert observed and observed[0]["requests"]
|
||||
|
||||
|
||||
def test_registered_mode_match_takes_precedence_over_same_named_template() -> None:
|
||||
client = StructuredSceneClient()
|
||||
assistant = CodexAssistantService(client)
|
||||
@@ -856,6 +1102,18 @@ def test_unknown_mode_uses_one_structured_turn_then_local_dry_run() -> None:
|
||||
"unresolved_clauses": ["构建一个星夜模式"],
|
||||
"cloud_input_utterance": "构建一个星夜模式",
|
||||
}
|
||||
expected_semantics = scene_semantic_guidance("星夜模式")
|
||||
expected_semantics["peripheral_policy"] = {
|
||||
"available_action_count": 0,
|
||||
"required_refs": [],
|
||||
"selection_goal": (
|
||||
"Include as many semantically relevant scene-safe peripheral devices "
|
||||
"as are useful; normally choose activation plus at most one stable "
|
||||
"state action per device."
|
||||
),
|
||||
"required_reason": "No peripheral action is mandatory for this name.",
|
||||
}
|
||||
expected_context["scene_semantics"] = expected_semantics
|
||||
assert call["application_context"] == expected_context
|
||||
assert call["output_schema"]["type"] == "object"
|
||||
assert call["output_schema"]["additionalProperties"] is False
|
||||
|
||||
@@ -11,6 +11,8 @@ from cognitive_agent.application.assistant_capabilities import AssistantCapabili
|
||||
from cognitive_agent.application.scene_composer import (
|
||||
build_scene_output_schema,
|
||||
normalize_scene_output,
|
||||
scene_required_peripheral_refs,
|
||||
scene_semantic_guidance,
|
||||
)
|
||||
from cognitive_agent.application.seat_service import SeatDemoService
|
||||
|
||||
@@ -352,3 +354,150 @@ def test_production_scene_schema_remains_below_app_server_limit() -> None:
|
||||
encoded = json.dumps(schema, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
assert len(encoded) < 64 * 1024
|
||||
|
||||
|
||||
def test_schema_and_normalizer_accept_only_opaque_peripheral_refs() -> None:
|
||||
context = _context()
|
||||
context["peripheral_actions"] = [
|
||||
{
|
||||
"ref": "ir:0123456789abcdef01234567",
|
||||
"device_label": "氛围灯带",
|
||||
"action_label": "设置为红色",
|
||||
}
|
||||
]
|
||||
schema = build_scene_output_schema(context)
|
||||
raw = {
|
||||
"requests": [{"capability_ref": "ir:0123456789abcdef01234567"}],
|
||||
"ambiguities": [],
|
||||
"confidence": 0.91,
|
||||
}
|
||||
|
||||
assert Draft202012Validator(schema).is_valid(raw)
|
||||
assert not Draft202012Validator(schema).is_valid(
|
||||
{
|
||||
**raw,
|
||||
"requests": [
|
||||
{
|
||||
"capability_ref": "ir:invented",
|
||||
"slot": 7,
|
||||
"card_uid": "2CC8B103",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
normalized = normalize_scene_output(raw, context)
|
||||
assert normalized["requests"] == [
|
||||
{
|
||||
"domain": "self_extension",
|
||||
"capability": "infrared.emit",
|
||||
"target_scope": "ir:0123456789abcdef01234567",
|
||||
"operation": "emit",
|
||||
"duration_ms": None,
|
||||
"parameters": [
|
||||
{
|
||||
"name": "capability_ref",
|
||||
"value": "ir:0123456789abcdef01234567",
|
||||
}
|
||||
],
|
||||
"reason": (
|
||||
"Scene Composer selected a server-issued learned-device reference; "
|
||||
"runtime binding resolution is still required."
|
||||
),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_schema_remains_bounded_with_full_peripheral_catalog() -> None:
|
||||
context = _context()
|
||||
context["peripheral_actions"] = [
|
||||
{"ref": f"ir:{index:024x}"}
|
||||
for index in range(248)
|
||||
]
|
||||
|
||||
schema = build_scene_output_schema(context)
|
||||
encoded = json.dumps(schema, ensure_ascii=False, separators=(",", ":")).encode()
|
||||
|
||||
assert len(encoded) < 64 * 1024
|
||||
peripheral_branch = schema["properties"]["requests"]["items"]["anyOf"][-1]
|
||||
assert len(
|
||||
peripheral_branch["properties"]["capability_ref"]["enum"]
|
||||
) == 248
|
||||
|
||||
|
||||
def test_learning_mode_is_grounded_as_free_semantic_composition() -> None:
|
||||
context = _context()
|
||||
guidance = scene_semantic_guidance("学习模式")
|
||||
context["scene_semantics"] = guidance
|
||||
schema = build_scene_output_schema(context)
|
||||
validator = Draft202012Validator(schema)
|
||||
valid = _output(
|
||||
_request(
|
||||
"climate.set_temperature",
|
||||
"vehicle",
|
||||
"temperature_c",
|
||||
24,
|
||||
)
|
||||
)
|
||||
|
||||
assert guidance["grounded"] is True
|
||||
assert guidance["family"] == "focused_learning"
|
||||
assert scene_semantic_guidance("灯光主题模式")["family"] == (
|
||||
"lighting_ambience"
|
||||
)
|
||||
assert "interior_light.set_reading_power" in guidance[
|
||||
"preferred_capability_ids"
|
||||
]
|
||||
assert schema["properties"]["requests"]["minItems"] == 1
|
||||
assert schema["properties"]["ambiguities"]["maxItems"] == 0
|
||||
assert validator.is_valid(valid)
|
||||
assert not validator.is_valid(
|
||||
{"requests": [], "ambiguities": [], "confidence": 0.4}
|
||||
)
|
||||
assert not validator.is_valid(
|
||||
{**valid, "ambiguities": ["不知道学习指什么"]}
|
||||
)
|
||||
with pytest.raises(ValueError, match="at least one request"):
|
||||
normalize_scene_output(
|
||||
{"requests": [], "ambiguities": [], "confidence": 0.4},
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode_name", ("浪漫模式", "中秋欢快模式"))
|
||||
def test_romantic_and_mid_autumn_require_flashing_light_actions(
|
||||
mode_name: str,
|
||||
) -> None:
|
||||
context = _context()
|
||||
context["peripheral_actions"] = [
|
||||
{
|
||||
"ref": "ir:flash-open",
|
||||
"device_label": "闪烁灯",
|
||||
"object_names": ["闪烁灯", "闪光灯"],
|
||||
"action_label": "打开",
|
||||
"action_names": ["打开", "开启"],
|
||||
},
|
||||
{
|
||||
"ref": "ir:flash-red",
|
||||
"device_label": "闪烁灯",
|
||||
"object_names": ["闪烁灯", "闪光灯"],
|
||||
"action_label": "切换为红色",
|
||||
"action_names": ["切换为红色", "设置为红色"],
|
||||
},
|
||||
{
|
||||
"ref": "ir:flash-close",
|
||||
"device_label": "闪烁灯",
|
||||
"object_names": ["闪烁灯", "闪光灯"],
|
||||
"action_label": "关闭",
|
||||
"action_names": ["关闭", "熄灭"],
|
||||
},
|
||||
]
|
||||
required = scene_required_peripheral_refs(mode_name, context)
|
||||
semantics = scene_semantic_guidance(mode_name)
|
||||
semantics["peripheral_policy"] = {"required_refs": required}
|
||||
context["scene_semantics"] = semantics
|
||||
schema = build_scene_output_schema(context)
|
||||
|
||||
assert required == ["ir:flash-open", "ir:flash-red"]
|
||||
assert schema["properties"]["requests"]["maxItems"] == 10
|
||||
if "中秋" in mode_name:
|
||||
assert semantics["family"] == "festival_celebration"
|
||||
|
||||
@@ -106,6 +106,19 @@ class SelfExtensionTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(synced["count"], 1)
|
||||
self.assertRegex(synced["catalog_revision"], r"^[0-9a-f]{16}$")
|
||||
self.assertEqual(
|
||||
synced["catalog_revision"], service.peripheral_catalog_revision()
|
||||
)
|
||||
with self.assertRaisesRegex(GatewayError, "其他会话更新"):
|
||||
service.sync_peripheral_catalog(
|
||||
[],
|
||||
expected_revision="stale-revision",
|
||||
)
|
||||
self.assertEqual(
|
||||
[item["id"] for item in service.list_peripheral_capabilities()],
|
||||
["toy-001-light-on"],
|
||||
)
|
||||
with closing(sqlite3.connect(database_path)) as connection, connection:
|
||||
connection.execute(
|
||||
"UPDATE infrared_slots SET device_id = '', "
|
||||
@@ -696,6 +709,73 @@ class SelfExtensionTests(unittest.TestCase):
|
||||
self.assertEqual(changed["device_id"], "toy-light-v2")
|
||||
self.assertEqual(service.list_peripheral_capabilities(), [])
|
||||
|
||||
def test_nfc_card_can_be_reassigned_from_existing_to_new_device(self) -> None:
|
||||
with TemporaryDirectory() as directory:
|
||||
service = SelfExtensionService(
|
||||
SimulationGateway(),
|
||||
catalog_db_path=Path(directory, "infrared-slots.sqlite3"),
|
||||
)
|
||||
service.save_infrared_slot(
|
||||
1,
|
||||
{
|
||||
"device_id": "old-light",
|
||||
"object_names": ["旧闪烁灯"],
|
||||
"action_names": ["打开"],
|
||||
"source": "tcp",
|
||||
},
|
||||
)
|
||||
service.save_infrared_slot(
|
||||
21,
|
||||
{
|
||||
"device_id": "new-light",
|
||||
"object_names": ["新灯带"],
|
||||
"action_names": ["打开"],
|
||||
"source": "tcp",
|
||||
},
|
||||
)
|
||||
initial = service.sync_peripheral_catalog(
|
||||
[
|
||||
{
|
||||
"id": "ir-slot:old-light:1",
|
||||
"status": "learned",
|
||||
"enabled": True,
|
||||
"device_id": "old-light",
|
||||
"device_name": "旧闪烁灯",
|
||||
"object_names": ["旧闪烁灯"],
|
||||
"card_uid": "E113B203",
|
||||
"action": "打开",
|
||||
"action_names": ["打开"],
|
||||
"slot": 1,
|
||||
}
|
||||
]
|
||||
)
|
||||
rebound = service.sync_peripheral_catalog(
|
||||
[
|
||||
{
|
||||
"id": "ir-slot:new-light:21",
|
||||
"status": "learned",
|
||||
"enabled": True,
|
||||
"device_id": "new-light",
|
||||
"device_name": "新灯带",
|
||||
"object_names": ["新灯带"],
|
||||
"card_uid": "E113B203",
|
||||
"action": "打开",
|
||||
"action_names": ["打开"],
|
||||
"slot": 21,
|
||||
}
|
||||
],
|
||||
expected_revision=initial["catalog_revision"],
|
||||
)
|
||||
|
||||
self.assertEqual(rebound["count"], 1)
|
||||
self.assertEqual(
|
||||
[
|
||||
(item["device_id"], item["slot"], item["card_uid"])
|
||||
for item in service.list_peripheral_capabilities()
|
||||
],
|
||||
[("new-light", 21, "E113B203")],
|
||||
)
|
||||
|
||||
def test_new_structured_slot_is_parseable_before_nfc_authorization(self) -> None:
|
||||
with TemporaryDirectory() as directory:
|
||||
service = SelfExtensionService(
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE_PATH = REPOSITORY_ROOT / "apps" / "web-console" / "self-extension.js"
|
||||
|
||||
|
||||
def _function_source(source: str, signature: str, next_signature: str) -> str:
|
||||
start = source.index(signature)
|
||||
end = source.index(next_signature, start)
|
||||
return source[start:end]
|
||||
|
||||
|
||||
def test_nfc_binding_waits_for_durable_catalog_before_success() -> None:
|
||||
source = SOURCE_PATH.read_text(encoding="utf-8")
|
||||
bind = _function_source(
|
||||
source,
|
||||
" async function bindCurrentNfcToExisting() {",
|
||||
"\n async function unbindCurrentNfc()",
|
||||
)
|
||||
|
||||
persistence = bind.index("await bindCardToDevice(device, cardUid);")
|
||||
success = bind.index("NFC 已关联设备 ID")
|
||||
assert persistence < success
|
||||
assert "NFC 设备授权保存失败" in bind
|
||||
assert "void bindCardToDevice" not in bind
|
||||
|
||||
|
||||
def test_peripheral_catalog_sync_is_serialized_and_tracks_backend_revision() -> None:
|
||||
source = SOURCE_PATH.read_text(encoding="utf-8")
|
||||
sync = _function_source(
|
||||
source,
|
||||
" async function syncPeripheralCatalog(",
|
||||
"\n async function bindCardToDevice",
|
||||
)
|
||||
|
||||
assert "peripheralCatalogSyncQueue" in sync
|
||||
assert "catalog_revision: peripheralCatalogRevision" in sync
|
||||
assert "result.catalog_revision" in sync
|
||||
assert "if (throwOnError) throw error;" in sync
|
||||
|
||||
|
||||
def test_bound_card_can_be_reassigned_to_a_new_device() -> None:
|
||||
source = SOURCE_PATH.read_text(encoding="utf-8")
|
||||
render = _function_source(
|
||||
source,
|
||||
" function renderNfcDialog(",
|
||||
"\n function buildUnifiedNfcDialog",
|
||||
)
|
||||
bind = _function_source(
|
||||
source,
|
||||
" async function bindCurrentNfcToExisting() {",
|
||||
"\n async function unbindCurrentNfc()",
|
||||
)
|
||||
|
||||
assert "alternativeDeviceGroups" in render
|
||||
assert "item.deviceId !== linkedDevice.id" in render
|
||||
assert "state.devices.find((device) => device.id === item.deviceId)?.cardUid" in render
|
||||
assert "data-nfc-bind-existing" in render
|
||||
assert "将当前 NFC 改绑到新设备 ID" in render
|
||||
assert "const previousDevice = linkedDeviceForCard(cardUid);" in bind
|
||||
assert "NFC 已从设备 ID" in bind
|
||||
assert "if (!capabilities.length)" in bind
|
||||
|
||||
|
||||
def test_device_card_surfaces_extension_toy_mqtt_delivery() -> None:
|
||||
source = SOURCE_PATH.read_text(encoding="utf-8")
|
||||
apply_toy = _function_source(
|
||||
source,
|
||||
" function applyExtensionToySnapshot(",
|
||||
"\n function applyNfcSnapshot",
|
||||
)
|
||||
apply_nfc = _function_source(
|
||||
source,
|
||||
" function applyNfcSnapshot(",
|
||||
"\n async function refreshNfcStatus",
|
||||
)
|
||||
|
||||
assert 'state === "burst_complete"' in apply_toy
|
||||
assert 'state === "burst_failed"' in apply_toy
|
||||
assert "burst_published_count" in apply_toy
|
||||
assert "extensionToySnapshot.last_error" in apply_toy
|
||||
assert "applyExtensionToySnapshot(value.extension_toy" in apply_nfc
|
||||
assert "正在准备 extension/toy 消息" in apply_nfc
|
||||
@@ -31,7 +31,7 @@ def test_vue_vite_application_is_the_primary_frontend_entry() -> None:
|
||||
assert package["scripts"]["build"] == "vite build"
|
||||
assert 'import vue from "@vitejs/plugin-vue"' in vite
|
||||
assert 'outDir: outputRoot' in vite
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v324-firmware-notification"' in service_worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v328-nfc-mqtt"' in service_worker
|
||||
assert "unpkg.com" not in html
|
||||
assert "cdn.jsdelivr.net" not in html
|
||||
|
||||
|
||||
Reference in New Issue
Block a user