feat(scenes): add deterministic romantic cinema template

This commit is contained in:
Miayui
2026-09-21 12:07:49 +08:00
parent b3bd90f748
commit ad0c1f4ed1
8 changed files with 453 additions and 23 deletions
@@ -0,0 +1,17 @@
id: romantic-cinema
name: 浪漫影院模式
aliases: [浪漫观影模式, 浪漫电影模式, 约会影院模式]
description: 完整保留观影模式的座椅、温控、遮光和车内灯光功能,并在运行时确定性加入当前已授权的外设灯光控制。
icon: heart
operations:
- {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 完整保留影院模式的主驾放松按摩;不移动主驾座椅。}
- {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 完整保留影院模式的副驾低档通风。}
- {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 55}, reason: 完整保留影院模式的副驾靠背姿态。}
- {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 45}, reason: 完整保留影院模式的副驾腿部支撑。}
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 完整保留影院模式的座舱恒温。}
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 完整保留影院模式的舒适中性温度。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 完整保留影院模式的低噪声风量。}
- {domain: roof, capability: roof.set_shade_position, target_scope: vehicle, operation: set, parameters: {position_pct: 0}, reason: 完整保留影院模式的遮光设置。}
- {domain: ambient_light, capability: ambient_light.set_mode, target_scope: vehicle, operation: set, parameters: {mode: cinema}, reason: 完整保留影院模式的车内影院氛围。}
- {domain: interior_light, capability: interior_light.set_indoor_power, target_scope: vehicle, operation: set, parameters: {state: "off"}, reason: 完整保留影院模式的室内主照明设置。}
- {domain: interior_light, capability: interior_light.set_reading_power, target_scope: all, operation: set, parameters: {state: "off"}, reason: 完整保留影院模式的阅读灯设置。}
@@ -48,7 +48,10 @@ from cognitive_agent.application.scene_composer import (
scene_required_peripheral_refs, scene_required_peripheral_refs,
scene_semantic_guidance, scene_semantic_guidance,
) )
from cognitive_agent.application.scene_templates import normalize_scene_template_name from cognitive_agent.application.scene_templates import (
PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS,
normalize_scene_template_name,
)
from cognitive_agent.application.schedule_intent import is_schedule_stop, needs_mode_schedule from cognitive_agent.application.schedule_intent import is_schedule_stop, needs_mode_schedule
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@@ -1034,15 +1037,53 @@ class CodexAssistantService:
if mode_parts if mode_parts
else None else None
) )
scene_template_plan = ( candidate_scene_template_plan = (
_catalog_exact_scene_template_plan(catalog, mode_parts[0]) _catalog_exact_scene_template_plan(catalog, mode_parts[0])
if mode_parts and not mode_parts[1] and exact_mode_matches == 0 if mode_parts and not mode_parts[1]
else None else None
) )
candidate_scene_template_metadata = (
candidate_scene_template_plan.get("metadata")
if isinstance(candidate_scene_template_plan, Mapping)
else None
)
candidate_scene_template_id = (
str(candidate_scene_template_metadata.get("template_id") or "")
if isinstance(candidate_scene_template_metadata, Mapping)
else ""
)
scene_template_plan = (
candidate_scene_template_plan
if exact_mode_matches == 0
or candidate_scene_template_id
in PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS
else None
)
scene_template_metadata = (
scene_template_plan.get("metadata")
if isinstance(scene_template_plan, Mapping)
else None
)
preserve_base_external_lights = bool(
isinstance(scene_template_metadata, Mapping)
and str(scene_template_metadata.get("template_id") or "")
in PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS
)
if preserve_base_external_lights:
# The template already owns every native cinema action. Do not let
# generic romantic-scene defaults replace or append native actions;
# only the deterministic, NFC-authorized light refs may be added.
scene_semantics = scene_context.get("scene_semantics")
if isinstance(scene_semantics, dict):
scene_semantics["required_native_domains"] = []
scene_semantics["required_native_controls"] = []
scene_semantics["native_fallback_requests"] = []
scene_semantics["default_control_bundle_applied"] = False
dynamic_scene_request = bool(mode_parts and needs_mode_schedule(message)) dynamic_scene_request = bool(mode_parts and needs_mode_schedule(message))
scene_peripheral_actions = scene_context.get("peripheral_actions", []) scene_peripheral_actions = scene_context.get("peripheral_actions", [])
template_augmentation_preview = bool( template_augmentation_preview = bool(
scene_template_plan is not None scene_template_plan is not None
and not preserve_base_external_lights
and not dynamic_scene_request and not dynamic_scene_request
and isinstance(scene_peripheral_actions, list) and isinstance(scene_peripheral_actions, list)
and scene_peripheral_actions and scene_peripheral_actions
@@ -33,6 +33,10 @@ _DOCUMENT_FIELDS = frozenset(
{"id", "name", "aliases", "description", "icon", "operations", "schedule"} {"id", "name", "aliases", "description", "icon", "operations", "schedule"}
) )
# These templates keep their repository-backed native plan byte-for-byte and
# only add currently authorized external-light references at request time.
PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS = frozenset({"romantic-cinema"})
def normalize_scene_template_name(value: object) -> str: def normalize_scene_template_name(value: object) -> str:
"""Normalize an exact user-facing name without enabling fuzzy matching.""" """Normalize an exact user-facing name without enabling fuzzy matching."""
@@ -289,6 +293,7 @@ class SceneTemplateRegistry:
__all__ = [ __all__ = [
"PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS",
"SceneTemplate", "SceneTemplate",
"SceneTemplateRegistry", "SceneTemplateRegistry",
"normalize_scene_template_name", "normalize_scene_template_name",
+122 -16
View File
@@ -116,7 +116,10 @@ from cognitive_agent.application.native_can_runtime import ( # noqa: E402
NativeCanExecutor, NativeCanExecutor,
NativeCanRuntime, NativeCanRuntime,
) )
from cognitive_agent.application.scene_templates import SceneTemplateRegistry # noqa: E402 from cognitive_agent.application.scene_templates import ( # noqa: E402
PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS,
SceneTemplateRegistry,
)
from cognitive_agent.application.schedule_intent import ( # noqa: E402 from cognitive_agent.application.schedule_intent import ( # noqa: E402
is_schedule_stop, is_schedule_stop,
needs_mode_schedule, needs_mode_schedule,
@@ -745,14 +748,14 @@ class SeatDemoService(ModeScheduleServiceMixin):
merged[existing] = action merged[existing] = action
return merged return merged
def _n7_cinema_base_scene( def _n7_cinema_base_profile(
self, self,
incoming_scene: Mapping[str, Any], incoming_scene: Mapping[str, Any],
) -> dict[str, Any] | None: ) -> ModeProfile | None:
if self._is_n7_cinema_base_scene(incoming_scene): if self._is_n7_cinema_base_scene(incoming_scene):
return None return None
vehicle_id = self.vehicle_profiles.default().id vehicle_id = self.vehicle_profiles.default().id
candidates: list[tuple[int, dict[str, Any]]] = [] candidates: list[tuple[int, ModeProfile]] = []
for public_profile in self.mode_profiles.public_modes(vehicle_id): for public_profile in self.mode_profiles.public_modes(vehicle_id):
try: try:
profile = self.mode_profiles.get_profile( profile = self.mode_profiles.get_profile(
@@ -774,12 +777,26 @@ class SeatDemoService(ModeScheduleServiceMixin):
): ):
continue continue
priority = 0 if str(source_scene.get("id") or "") == "cinema" else 1 priority = 0 if str(source_scene.get("id") or "") == "cinema" else 1
candidates.append((priority, deepcopy(dict(source_scene)))) candidates.append((priority, profile))
if not candidates: if not candidates:
return None return None
candidates.sort(key=lambda item: (item[0], str(item[1].get("id") or ""))) candidates.sort(key=lambda item: (item[0], item[1].id))
return candidates[0][1] return candidates[0][1]
def _n7_cinema_base_scene(
self,
incoming_scene: Mapping[str, Any],
) -> dict[str, Any] | None:
profile = self._n7_cinema_base_profile(incoming_scene)
if profile is None:
return None
source_scene = (
profile.command_flow.get("source_scene")
if isinstance(profile.command_flow, Mapping)
else None
)
return deepcopy(dict(source_scene)) if isinstance(source_scene, Mapping) else None
@staticmethod @staticmethod
def _is_n7_cinema_video_launch_action(action: Mapping[str, Any]) -> bool: def _is_n7_cinema_video_launch_action(action: Mapping[str, Any]) -> bool:
parameters = action.get("parameters") parameters = action.get("parameters")
@@ -1428,9 +1445,22 @@ class SeatDemoService(ModeScheduleServiceMixin):
requests = self.mode_profiles.requests_for_profiles([profile]) requests = self.mode_profiles.requests_for_profiles([profile])
if profile.sync_origin != MQTT_MODE_SCENE_INGRESS_TOPIC: if profile.sync_origin != MQTT_MODE_SCENE_INGRESS_TOPIC:
transfer_actions = self._local_cinema_n7_transfer_actions( stored_transfer_actions = (
profile.name, profile.command_flow.get("n7_transfer_actions")
profile.instruction, if isinstance(profile.command_flow, Mapping)
else None
)
transfer_actions = (
[
deepcopy(dict(action))
for action in stored_transfer_actions[:256]
if isinstance(action, Mapping)
]
if isinstance(stored_transfer_actions, list)
else self._local_cinema_n7_transfer_actions(
profile.name,
profile.instruction,
)
) )
transfer_selectors = { transfer_selectors = {
self._n7_transfer_operation_selector( self._n7_transfer_operation_selector(
@@ -2020,8 +2050,22 @@ class SeatDemoService(ModeScheduleServiceMixin):
raise ValueError("动态模式更新必须保留完整时间轴,请按阶段重新生成预览。") raise ValueError("动态模式更新必须保留完整时间轴,请按阶段重新生成预览。")
if schedule is not None: if schedule is not None:
operations = [deepcopy(op) for phase in schedule.phases for op in phase.operations] operations = [deepcopy(op) for phase in schedule.phases for op in phase.operations]
source_template_id = (
str(source_draft.get("source_template_id") or "").strip()
if isinstance(source_draft, Mapping)
else ""
)
preview_transfer_candidates = source_result.get("n7_transfer_candidates")
n7_transfer_actions = ( n7_transfer_actions = (
self._local_cinema_n7_transfer_actions( [
deepcopy(dict(action))
for action in preview_transfer_candidates[:256]
if isinstance(action, Mapping)
]
if schedule is None
and source_template_id in PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS
and isinstance(preview_transfer_candidates, list)
else self._local_cinema_n7_transfer_actions(
clean_name, clean_name,
clean_instruction, clean_instruction,
) )
@@ -6966,13 +7010,34 @@ class SeatDemoService(ModeScheduleServiceMixin):
mode_name, mode_name,
vehicle_id=vehicle_profile.id, vehicle_id=vehicle_profile.id,
) )
if scene_template is not None and existing_profile is not None: preserve_base_external_lights = bool(
scene_template is not None
and scene_template.id in PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS
)
if (
scene_template is not None
and existing_profile is not None
and not preserve_base_external_lights
):
raise ValueError("同名功能模式已创建,请重新提交以载入最新模式。") raise ValueError("同名功能模式已创建,请重新提交以载入最新模式。")
source_profile = ( source_profile = (
existing_profile existing_profile
if not requested_instruction and source_draft is None if not requested_instruction and source_draft is None
and not preserve_base_external_lights
else None else None
) )
if (
source_profile is None
and source_draft is None
and not requested_instruction
and preserve_base_external_lights
):
source_profile = self._n7_cinema_base_profile(
{
"id": scene_template.id,
"name": mode_name,
}
)
if source_profile is None and source_draft is None and not requested_instruction and scene_template is None: if source_profile is None and source_draft is None and not requested_instruction and scene_template is None:
source_name = mode_creation_source_name(mode_name) source_name = mode_creation_source_name(mode_name)
if source_name != mode_name: if source_name != mode_name:
@@ -7000,7 +7065,18 @@ class SeatDemoService(ModeScheduleServiceMixin):
# of appending a model prompt to the user's utterance. The latter was # of appending a model prompt to the user's utterance. The latter was
# being parsed as a second control clause on the assistant's local fast # being parsed as a second control clause on the assistant's local fast
# path, turning a valid nine-action mode into an incomplete draft. # path, turning a valid nine-action mode into an incomplete draft.
source_utterance = requested_instruction or f"进入{draft_mode_name}" draft_instruction = requested_instruction or f"进入{draft_mode_name}"
inherited_n7_cinema_profile = bool(
source_profile is not None
and scene_template is not None
and scene_template.id in PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS
and source_profile.sync_origin == MQTT_MODE_SCENE_INGRESS_TOPIC
)
source_utterance = (
f"进入{source_profile.name}"
if inherited_n7_cinema_profile
else draft_instruction
)
if requested_instruction and selected_seats is not None and len(selected_seats) == 1: if requested_instruction and selected_seats is not None and len(selected_seats) == 1:
# Qualify only explicit seat-device clauses, never an HVAC/window # Qualify only explicit seat-device clauses, never an HVAC/window
# zone or unrelated control. General one-shot controls keep their # zone or unrelated control. General one-shot controls keep their
@@ -7056,7 +7132,19 @@ class SeatDemoService(ModeScheduleServiceMixin):
self._persist(result) self._persist(result)
return result return result
try: try:
if scene_template is not None and selected_seats is not None: preserve_unscoped_template_seats = bool(
scene_template is not None
and scene_template.id in PRESERVE_BASE_EXTERNAL_LIGHT_TEMPLATE_IDS
and resolve_explicit_seat_scopes(
utterance,
assume_seat_context=True,
) is None
)
if (
scene_template is not None
and selected_seats is not None
and not preserve_unscoped_template_seats
):
assistant_plan = project_seed_plan(assistant_plan, selected_seats) assistant_plan = project_seed_plan(assistant_plan, selected_seats)
source_result = self._process_once( source_result = self._process_once(
source_utterance, source_utterance,
@@ -7070,7 +7158,9 @@ class SeatDemoService(ModeScheduleServiceMixin):
direct_mode_profile_id=( direct_mode_profile_id=(
source_profile.id if source_profile is not None else None source_profile.id if source_profile is not None else None
), ),
mode_creation_scopes=selected_seats, mode_creation_scopes=(
None if preserve_unscoped_template_seats else selected_seats
),
assistant_plan=assistant_plan, assistant_plan=assistant_plan,
# ``source_utterance`` is synthesized for a name-only, unknown # ``source_utterance`` is synthesized for a name-only, unknown
# scene. It must not fall back to substring matching (for example # scene. It must not fall back to substring matching (for example
@@ -7086,7 +7176,11 @@ class SeatDemoService(ModeScheduleServiceMixin):
# already passed the ordinary text/target authorization path. # already passed the ordinary text/target authorization path.
# Its old display name cannot veto an explicitly requested new # Its old display name cannot veto an explicitly requested new
# seat. This override is internal, never accepted from payloads. # seat. This override is internal, never accepted from payloads.
scene_scope_utterance="" if source_draft is not None else utterance, scene_scope_utterance=(
""
if source_draft is not None or preserve_unscoped_template_seats
else utterance
),
) )
except ModeCreationSeatPolicyError as exc: except ModeCreationSeatPolicyError as exc:
return self._mode_creation_seat_failure(utterance, conversation_id, vehicle_profile, str(exc)) return self._mode_creation_seat_failure(utterance, conversation_id, vehicle_profile, str(exc))
@@ -7219,6 +7313,16 @@ class SeatDemoService(ModeScheduleServiceMixin):
typed_source_actions = [ typed_source_actions = [
action for action in source_actions if isinstance(action, dict) action for action in source_actions if isinstance(action, dict)
] if isinstance(source_actions, list) else [] ] if isinstance(source_actions, list) else []
if (
inherited_n7_cinema_profile
and source_result.get("status") == "partially_succeeded"
and typed_source_actions
and source_result.get("n7_transfer_candidates")
):
# N7-only actions are intentionally deferred during a dry-run. They
# remain part of the received cinema base and are persisted as
# transfer actions, so their presence is not a partial draft.
source_result["status"] = "succeeded"
if ( if (
source_result.get("status") == "succeeded" source_result.get("status") == "succeeded"
and generation_source and generation_source
@@ -7228,6 +7332,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
"scene_template_copy", "scene_template_copy",
} }
and assistant_plan is not None and assistant_plan is not None
and not inherited_n7_cinema_profile
and ( and (
self._scene_plan_signatures(assistant_plan) self._scene_plan_signatures(assistant_plan)
!= self._scene_action_signatures(typed_source_actions) != self._scene_action_signatures(typed_source_actions)
@@ -7372,9 +7477,10 @@ class SeatDemoService(ModeScheduleServiceMixin):
if source_draft is not None if source_draft is not None
else 0 else 0
) )
source_result["utterance"] = draft_instruction
source_result["mode_profile_draft"] = { source_result["mode_profile_draft"] = {
"name": draft_mode_name, "name": draft_mode_name,
"instruction": source_utterance, "instruction": draft_instruction,
"source_request_id": source_request_id, "source_request_id": source_request_id,
"vehicle_id": vehicle_profile.id, "vehicle_id": vehicle_profile.id,
"domains": domains, "domains": domains,
@@ -3,7 +3,7 @@ from __future__ import annotations
import json import json
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -65,6 +65,204 @@ def _learned_light_power_on() -> dict[str, object]:
} }
def _received_cinema_scene() -> dict[str, object]:
actions = [
{
"type": "window_position",
"at_ms": 0,
"domain": "window",
"capability": "window.set_position",
"target_scope": "front_left",
"operation": "set",
"parameters": {"position_pct": 0},
},
{
"type": "climate_temperature",
"at_ms": 0,
"domain": "climate",
"capability": "climate.set_temperature",
"target_scope": "vehicle",
"operation": "set",
"parameters": {"temperature_c": 25},
},
{
"type": "seat_preset",
"at_ms": 0,
"domain": "local_vehicle",
"capability": "local_scene.seat_preset",
"target_scope": "vehicle",
"operation": "set",
"parameters": {"targets_pulse": [246, 112, 405, 342]},
},
{
"type": "android_app_launch",
"at_ms": 3000,
"domain": "android",
"capability": "android.launch_app",
"target_scope": "headunit",
"operation": "launch",
"parameters": {
"package_name": "com.byd.videoplay",
"activity_name": "com.byd.videoplay.activity.WelComeActivity",
},
},
]
return {
"id": "cinema",
"label": "影院模式",
"name": "影院模式",
"description": "接收到的车机影院模式。",
"icon": "film",
"aliases": ["观影模式"],
"origin": "headunit",
"deletable": True,
"behavior": "static",
"domains": ["window", "climate", "local_vehicle", "android"],
"operation_count": len(actions),
"scope": {"common": False, "vehicle_ids": ["denza_n7"]},
"profile_revision": "received-cinema-v1",
"actions": actions,
}
def test_romantic_cinema_template_preserves_cinema_and_adds_authorized_light(
tmp_path: Path,
) -> None:
service = SeatDemoService(REPOSITORY_ROOT)
service.artifact_root = tmp_path / "runs"
service.mode_profiles = ModeProfileRegistry(
REPOSITORY_ROOT / "config" / "modes",
user_root=tmp_path / "modes",
)
extension = SelfExtensionService(
SimulationGateway(),
catalog_db_path=tmp_path / "infrared.sqlite3",
)
extension.sync_peripheral_catalog([_learned_light_power_on()])
received_cinema = _received_cinema_scene()
service.import_n7_mode_scene(received_cinema)
stale_profile = service.mode_profiles.save_mode(
"浪漫影院模式",
[
{
"domain": "climate",
"capability": "climate.set_temperature",
"target_scope": "vehicle",
"operation": "set",
"parameters": {"temperature_c": 30},
}
],
vehicle_id="denza_n7",
instruction="进入浪漫影院模式",
)
catalog = AssistantCapabilityCatalog(
service,
peripheral_provider=extension.list_peripheral_routing_capabilities,
)
handler = object.__new__(SeatDemoHandler)
handler.service = service
handler.extension = extension
handler.nfc = SimpleNamespace(snapshot=lambda: {"card": None})
handler.vehicle_display_sync = VehicleDisplaySync()
client = MagicMock()
client.available = True
assistant = CodexAssistantService(client)
snapshot = catalog.snapshot(vehicle_id="denza_n7")
result: dict[str, object] = {}
def execute_control(
instruction: str,
progress,
assistant_plan: dict[str, object],
) -> dict[str, object]:
nonlocal result
arguments = {
"utterance": instruction,
"provider": "local",
"conversation_id": "conv-romantic-cinema-template",
"execution_mode": "hardware",
"vehicle_id": "denza_n7",
"simulation_fault": "normal",
"hardware_access_bypass": True,
"reasoning_effort": "low",
"control_transport": "wired_can",
"assistant_plan": assistant_plan,
}
result = handler._preview_mode_with_scene_peripherals(
"/api/requests/preview",
arguments,
assistant_plan,
on_commands_validated=progress,
)
return result
try:
with patch.object(
extension,
"execute_gateway",
side_effect=AssertionError("mode creation must not emit infrared"),
):
response = assistant.stream(
"创建浪漫影院模式",
conversation_id="conv-romantic-cinema-template",
principal="romantic-cinema-template-test",
history=[],
emit=lambda _event: True,
execute_control=execute_control,
capability_snapshot=snapshot,
capability_context=catalog.compact_context(snapshot),
scene_capability_context=catalog.scene_composition_context(snapshot),
control_terms=catalog.control_terms(snapshot),
)
assert response["success"] is True
assert response["route"] == "scene_template"
client.start_structured_turn.assert_not_called()
client.start_turn.assert_not_called()
assert result["status"] == "succeeded"
assert result["mode_profile_draft"]["generation_source"] == "scene_template_copy"
assert result["mode_profile_draft"]["existing_mode_id"] == stale_profile.id
assert result["mode_profile_draft"]["operation_count"] == 3
native_actions = {
(
action["capability"],
action["target"].get("instance") or action["target"].get("zone"),
): action["parameters"]
for action in result["intent_ir"]["spec"]["actions"]
if action["domain"] != "self_extension"
}
assert native_actions == {
(action["capability"], action["target_scope"]): action["parameters"]
for action in received_cinema["actions"]
if action["domain"] in {"window", "climate"}
}
assert result["n7_transfer_candidates"] == received_cinema["actions"][2:]
assert [action["slot"] for action in result["infrared_actions"]] == [1]
draft = result["mode_profile_draft"]
saved = service.save_mode_profile_card(
stale_profile.id,
draft["name"],
draft["instruction"],
draft["source_request_id"],
draft["vehicle_id"],
)
profile = service.get_mode_profile_card(saved["mode_profile"]["id"], "denza_n7")
saved_selectors = {
(operation.capability, operation.target_scope)
for operation in profile.operations
}
assert saved_selectors == {
(action["capability"], action["target_scope"])
for action in received_cinema["actions"]
} | {("infrared.emit", "romance-light")}
_local_requests, transfer_actions = service._mode_requests_and_n7_transfers(profile)
assert transfer_actions == received_cinema["actions"][2:]
finally:
service.close()
def test_scene_composer_mixed_can_ir_preview_save_and_revalidate( def test_scene_composer_mixed_can_ir_preview_save_and_revalidate(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
@@ -165,7 +165,7 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run(
templates = snapshot["scene_templates"] templates = snapshot["scene_templates"]
assert isinstance(templates, list) assert isinstance(templates, list)
assert len(templates) == 16 assert len(templates) == 17
assert all(template["assistant_callable"] is True for template in templates) assert all(template["assistant_callable"] is True for template in templates)
with patch.object( with patch.object(
@@ -211,7 +211,9 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run(
else: else:
expected_requests = [ expected_requests = [
request for request in expected_requests request for request in expected_requests
if request["domain"] != "seat" or request["target_scope"] == "passenger" if template["id"] == "romantic-cinema"
or request["domain"] != "seat"
or request["target_scope"] == "passenger"
] ]
assert draft["operation_count"] == len(expected_requests) assert draft["operation_count"] == len(expected_requests)
expected = { expected = {
@@ -246,6 +248,23 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run(
assert not list(user_root.rglob("*.yaml")) assert not list(user_root.rglob("*.yaml"))
def test_romantic_cinema_template_is_an_exact_copy_of_all_cinema_functions(
hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path],
) -> None:
service, _catalog, _user_root = hybrid_service
cinema = service.scene_templates.get_template("cinema")
romantic_cinema = service.scene_templates.get_template("romantic-cinema")
assert len(cinema.operations) == len(romantic_cinema.operations) == 11
assert [
(operation.domain, operation.capability, operation.target_scope, operation.parameters)
for operation in romantic_cinema.operations
] == [
(operation.domain, operation.capability, operation.target_scope, operation.parameters)
for operation in cinema.operations
]
def test_viewing_template_seeds_a_complete_unbound_preview_without_codex( def test_viewing_template_seeds_a_complete_unbound_preview_without_codex(
hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path], hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path],
) -> None: ) -> None:
@@ -372,7 +372,7 @@ def test_common_scene_templates_are_read_only_callable_catalog_entries(
templates = snapshot["scene_templates"] templates = snapshot["scene_templates"]
assert snapshot["source_status"]["scene_templates"] == "ready" assert snapshot["source_status"]["scene_templates"] == "ready"
assert len(templates) == 16 assert len(templates) == 17
assert {item["name"] for item in templates} >= { assert {item["name"] for item in templates} >= {
"观影模式", "观影模式",
"露营模式", "露营模式",
@@ -701,6 +701,50 @@ def test_romantic_template_always_adds_authorized_decorative_lights() -> None:
assert "ir:flash-close-00000000003" not in _peripheral_refs(observed[0]) assert "ir:flash-close-00000000003" not in _peripheral_refs(observed[0])
def test_romantic_cinema_template_keeps_native_base_and_adds_lights_without_model() -> None:
client = StructuredSceneClient()
assistant = CodexAssistantService(client)
template = _scene_template(
template_id="romantic-cinema",
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-cinema-deterministic",
catalog=_catalog_with_templates(template),
scene_context=_scene_context_with_flashing_light(),
)
assert result["success"] is True
assert result["route"] == "scene_template"
assert client.structured_calls == []
assert client.legacy_calls == []
native_requests = [
request
for request in observed[0]["requests"]
if request.get("domain") != "self_extension"
]
assert native_requests == template["plan"]["requests"]
assert _peripheral_refs(observed[0]) == [
"ir:flash-open-000000000001",
"ir:ambient-open-0000000001",
"ir:strip-open-000000000001",
]
def test_mid_autumn_cloud_composition_adds_required_native_and_external_controls() -> None: def test_mid_autumn_cloud_composition_adds_required_native_and_external_controls() -> None:
client = StructuredSceneClient() client = StructuredSceneClient()
assistant = CodexAssistantService(client) assistant = CodexAssistantService(client)