feat: add seat-scoped static and dynamic assistant modes
This commit is contained in:
@@ -715,9 +715,16 @@ def test_every_catalog_mode_rejects_incompatible_explicit_seat_scopes(
|
||||
|
||||
for mode_index, mode in enumerate(modes):
|
||||
assert isinstance(mode, Mapping)
|
||||
profile = vehicle_service.mode_profiles.get_profile(str(mode["id"]), "denza_n7")
|
||||
has_driver = any(
|
||||
operation.domain == "seat" and operation.target_scope in {"driver", "front_left"}
|
||||
for operation in profile.operations
|
||||
)
|
||||
for scope_index, scope in enumerate(
|
||||
("主驾", "驾驶员", "后排", "二排", "前排", "所有座椅", "全车座椅")
|
||||
):
|
||||
if has_driver and scope in {"主驾", "驾驶员"}:
|
||||
continue # Valid dual-seat subsets have a separate exact-action matrix.
|
||||
message = f"请让{scope}进入{mode['name']}"
|
||||
result = vehicle_service.preview_native_can(
|
||||
message,
|
||||
@@ -754,7 +761,7 @@ def test_every_catalog_mode_rejects_incompatible_explicit_seat_scopes(
|
||||
def test_preview_and_process_share_mode_target_safety_gate(
|
||||
vehicle_service: SeatDemoService,
|
||||
) -> None:
|
||||
message = "主驾开启小憩模式"
|
||||
message = "主驾开启零重力模式"
|
||||
direct = vehicle_service.process(
|
||||
message,
|
||||
provider="local",
|
||||
|
||||
@@ -754,7 +754,11 @@ def test_incomplete_ambiguous_or_unsupported_scene_never_creates_partial_draft(
|
||||
|
||||
assert result["status"] != "succeeded"
|
||||
assert result.get("mode_profile_draft") is None
|
||||
assert result.get("orchestration_route") != "mode_profile_create_preview"
|
||||
# Seat policy can reject on the creation route before compiling any action.
|
||||
assert not (result.get("intent_ir") or {}).get("spec", {}).get("actions")
|
||||
assert not result.get("actions")
|
||||
assert result.get("command_plan") is None
|
||||
assert not result.get("command_plans")
|
||||
assert result.get("validation")
|
||||
failure = result.get("mode_creation_failure")
|
||||
assert isinstance(failure, dict)
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
"""Creation selects one seat by default without remapping incompatible actuators."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
from copy import deepcopy
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog
|
||||
from cognitive_agent.application.codex_assistant import ActiveCodexTurn, CodexAssistantService
|
||||
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
||||
from cognitive_agent.application.scene_composer import normalize_scene_output
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def policy_service(tmp_path, monkeypatch):
|
||||
for variable in (
|
||||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED",
|
||||
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED",
|
||||
"COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED",
|
||||
):
|
||||
monkeypatch.setenv(variable, "false")
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service.artifact_root = tmp_path / "artifacts"
|
||||
user_root = tmp_path / "user-modes"
|
||||
service.mode_profiles = ModeProfileRegistry(
|
||||
REPOSITORY_ROOT / "config" / "modes", user_root=user_root,
|
||||
)
|
||||
try:
|
||||
with (
|
||||
patch.object(service.hardware_execution, "reserve", side_effect=AssertionError("physical CAN forbidden")) as reserve,
|
||||
patch.object(service._vehicle_mqtt_transport, "bus", side_effect=AssertionError("physical MQTT forbidden")) as mqtt,
|
||||
):
|
||||
yield service, AssistantCapabilityCatalog(service), user_root
|
||||
reserve.assert_not_called()
|
||||
mqtt.assert_not_called()
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
|
||||
def _actions(result):
|
||||
return (result.get("intent_ir") or {}).get("spec", {}).get("actions", [])
|
||||
|
||||
|
||||
def _seat_scopes(result):
|
||||
return {
|
||||
action["target"]["instance"] for action in _actions(result)
|
||||
if action["domain"] == "seat"
|
||||
}
|
||||
|
||||
|
||||
def _operation_key(operation):
|
||||
target = operation.get("target_scope")
|
||||
if target is None:
|
||||
raw_target = operation.get("target") or {}
|
||||
target = raw_target.get("instance") or raw_target.get("zone")
|
||||
return (
|
||||
operation["capability"], target,
|
||||
json.dumps(operation["parameters"], ensure_ascii=False, sort_keys=True),
|
||||
)
|
||||
|
||||
|
||||
def _preview(service, message, *, plan=None, draft=None):
|
||||
return service.preview_native_can(
|
||||
message, provider="local", conversation_id="conv-seat-policy",
|
||||
execution_mode="hardware", vehicle_id="denza_n7",
|
||||
assistant_plan=plan, mode_profile_draft_context=draft,
|
||||
)
|
||||
|
||||
|
||||
def _assert_created(result, scopes):
|
||||
assert result["status"] == "succeeded", result.get("validation")
|
||||
assert result.get("mode_profile_draft"), result
|
||||
assert result["execution_mode"] == "dry_run"
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
assert _seat_scopes(result) == set(scopes)
|
||||
schedule = result.get("schedule")
|
||||
if schedule:
|
||||
for phase in schedule["phases"]:
|
||||
assert {
|
||||
op["target_scope"] for op in phase["operations"] if op["domain"] == "seat"
|
||||
} <= set(scopes)
|
||||
assert result["mode_profile_draft"]["schedule"] == schedule
|
||||
|
||||
|
||||
def _assistant_preview(service, catalog, message, *, client=None):
|
||||
if client is None:
|
||||
client = MagicMock()
|
||||
client.available = False
|
||||
assistant = CodexAssistantService(client)
|
||||
snapshot = catalog.snapshot(vehicle_id="denza_n7")
|
||||
fast = catalog.fast_answer(message, snapshot)
|
||||
results, events = [], []
|
||||
|
||||
def execute(instruction, progress, assistant_plan=None):
|
||||
result = _preview(service, instruction, plan=assistant_plan)
|
||||
results.append(result)
|
||||
return result
|
||||
|
||||
response = assistant.stream(
|
||||
message, conversation_id="conv-seat-policy", principal="seat-policy-test", history=[],
|
||||
authoritative_answer=fast.answer if fast else None,
|
||||
emit=lambda event: events.append(event) or True,
|
||||
execute_control=execute, capability_snapshot=snapshot,
|
||||
capability_context=catalog.compact_context(snapshot),
|
||||
scene_capability_context=catalog.scene_composition_context(snapshot),
|
||||
control_terms=catalog.control_terms(snapshot),
|
||||
)
|
||||
assert len(results) == 1, (response, results)
|
||||
return response, results[0], client
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", [
|
||||
"阅读模式", "观影模式", "办公模式", "座椅舒适模式", "放松模式", "座椅动态模式", "上车预热模式",
|
||||
])
|
||||
@pytest.mark.parametrize(("prefix", "scopes"), [
|
||||
("", {"passenger"}), ("主驾", {"driver"}), ("主驾和副驾", {"driver", "passenger"}),
|
||||
])
|
||||
def test_registered_modes_use_selected_seat_through_assistant_fast_path(policy_service, name, prefix, scopes):
|
||||
service, catalog, user_root = policy_service
|
||||
response, preview, client = _assistant_preview(service, catalog, f"构建一个{prefix}{name}")
|
||||
assert response["success"] is True, (response, preview)
|
||||
_assert_created(preview, scopes)
|
||||
client.start_thread.assert_not_called()
|
||||
client.start_turn.assert_not_called()
|
||||
client.start_structured_turn.assert_not_called()
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("message", "scopes"), [
|
||||
("构建一个两个座椅的阅读模式", {"driver", "passenger"}),
|
||||
("构建一个两张座椅的阅读模式", {"driver", "passenger"}),
|
||||
("构建一个两座椅的阅读模式", {"driver", "passenger"}),
|
||||
("构建一个前排两座的阅读模式", {"driver", "passenger"}),
|
||||
("构建一个副驾驶席阅读模式", {"passenger"}),
|
||||
("构建一个副驾驶位阅读模式", {"passenger"}),
|
||||
("构建一个副驾驶座椅阅读模式", {"passenger"}),
|
||||
("构建一个两个座椅的动态模式", {"driver", "passenger"}),
|
||||
("构建一个两张座椅的动态模式", {"driver", "passenger"}),
|
||||
("构建一个两座椅的动态模式", {"driver", "passenger"}),
|
||||
("构建一个前排两座的动态模式", {"driver", "passenger"}),
|
||||
("构建一个副驾驶座椅动态模式", {"passenger"}),
|
||||
])
|
||||
def test_registered_seat_aliases_select_correct_targets_without_model(policy_service, message, scopes):
|
||||
service, catalog, _ = policy_service
|
||||
response, preview, client = _assistant_preview(service, catalog, message)
|
||||
assert response["success"] is True, (
|
||||
response["answer"], preview.get("validation"), preview.get("mode_profile_draft"),
|
||||
)
|
||||
_assert_created(preview, scopes)
|
||||
assert response["route"] == "scene_template"
|
||||
client.start_structured_turn.assert_not_called()
|
||||
client.start_turn.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template_id", [
|
||||
"reading", "cinema", "camping", "quiet-night", "reception", "romantic",
|
||||
"winter-warmth", "summer-cooling", "long-trip-comfort", "relaxation", "seat-comfort",
|
||||
"seat-dynamic", "boarding-warmup",
|
||||
])
|
||||
@pytest.mark.parametrize(("prefix", "scope"), [("", "passenger"), ("主驾", "driver")])
|
||||
def test_trusted_template_selection_keeps_nonseat_devices_and_native_parameters(policy_service, template_id, prefix, scope):
|
||||
service, _, _ = policy_service
|
||||
template = service.scene_templates.get_template(template_id)
|
||||
original = deepcopy(template.plan())
|
||||
result = _preview(service, f"构建一个{prefix}{template.name}", plan=template.plan())
|
||||
_assert_created(result, {scope})
|
||||
raw_operations = (
|
||||
[op for phase in original["schedule"]["phases"] for op in phase["operations"]]
|
||||
if original.get("schedule") else [op.to_yaml() for op in template.operations]
|
||||
)
|
||||
expected = [
|
||||
_operation_key(op) for op in raw_operations
|
||||
if op["domain"] != "seat" or op["target_scope"] == scope
|
||||
]
|
||||
assert sorted(map(_operation_key, _actions(result))) == sorted(expected)
|
||||
assert template.plan() == original, "projecting a request must not mutate the registered template"
|
||||
|
||||
|
||||
def _model_output(scopes, *, dynamic=False):
|
||||
requests = []
|
||||
if "driver" in scopes:
|
||||
requests.append({
|
||||
"capability": "seat.set_driver_massage_native", "target_scope": "driver",
|
||||
"parameters": {"action": "mode", "mode": "relax"},
|
||||
})
|
||||
if "passenger" in scopes:
|
||||
requests.append({
|
||||
"capability": "seat.set_backrest_recline", "target_scope": "passenger",
|
||||
"parameters": {"position_pct": 45},
|
||||
})
|
||||
requests.append({
|
||||
"capability": "climate.set_temperature", "target_scope": "vehicle",
|
||||
"parameters": {"temperature_c": 24},
|
||||
})
|
||||
if not dynamic:
|
||||
return {"requests": requests, "ambiguities": [], "confidence": 0.95}
|
||||
return {
|
||||
"requests": [], "ambiguities": [], "confidence": 0.95,
|
||||
"schedule": {
|
||||
"repeat_count": 2,
|
||||
"phases": [
|
||||
{"id": "relax", "name": "舒缓", "duration_ms": 30000, "requests": deepcopy(requests)},
|
||||
{"id": "support", "name": "支撑", "duration_ms": 60000, "requests": deepcopy(requests)},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dynamic", [False, True])
|
||||
@pytest.mark.parametrize(("prefix", "scopes"), [
|
||||
("", {"passenger"}), ("主驾", {"driver"}), ("主驾和副驾", {"driver", "passenger"}),
|
||||
])
|
||||
def test_unregistered_model_plan_preserves_correct_explicit_or_default_targets(policy_service, dynamic, prefix, scopes):
|
||||
service, catalog, _ = policy_service
|
||||
plan = normalize_scene_output(
|
||||
_model_output(scopes, dynamic=dynamic), catalog.scene_composition_context(), dynamic=dynamic,
|
||||
)
|
||||
dynamic_label = "动态" if dynamic else ""
|
||||
result = _preview(service, f"构建一个{prefix}星河释压{dynamic_label}模式", plan=plan)
|
||||
_assert_created(result, scopes)
|
||||
assert result["mode_profile_draft"]["generation_source"] == "codex_scene_composition"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dynamic", [False, True])
|
||||
@pytest.mark.parametrize("prefix", ["", "主驾", "副驾"])
|
||||
def test_unregistered_model_cannot_invent_cross_seat_actions(policy_service, dynamic, prefix):
|
||||
service, catalog, user_root = policy_service
|
||||
plan = normalize_scene_output(
|
||||
_model_output({"driver", "passenger"}, dynamic=dynamic),
|
||||
catalog.scene_composition_context(), dynamic=dynamic,
|
||||
)
|
||||
try:
|
||||
result = _preview(service, f"构建一个{prefix}星河释压{'动态' if dynamic else ''}模式", plan=plan)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
assert result["status"] != "succeeded"
|
||||
assert not result.get("mode_profile_draft")
|
||||
assert not _actions(result)
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("prefix", "requested_scope", "unexpected_scope"), [
|
||||
("", "passenger", "driver"), ("主驾", "driver", "passenger"),
|
||||
])
|
||||
def test_unexpected_seat_only_in_later_dynamic_phase_rejects_entire_draft(
|
||||
policy_service, prefix, requested_scope, unexpected_scope,
|
||||
):
|
||||
service, catalog, user_root = policy_service
|
||||
output = _model_output({requested_scope}, dynamic=True)
|
||||
output["schedule"]["phases"][1]["requests"] = _model_output({unexpected_scope})["requests"]
|
||||
plan = normalize_scene_output(output, catalog.scene_composition_context(), dynamic=True)
|
||||
try:
|
||||
result = _preview(service, f"构建一个{prefix}星河释压动态模式", plan=plan)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
assert result["status"] != "succeeded"
|
||||
assert not result.get("mode_profile_draft")
|
||||
assert not _actions(result)
|
||||
assert not result.get("command_plans")
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", [
|
||||
"构建一个办公模式,空调先24度持续30秒,然后24度持续60秒,循环2次",
|
||||
"构建一个办公模式,主驾座椅分两个阶段动态舒缓",
|
||||
])
|
||||
def test_existing_mode_binding_cannot_authorize_cross_seat_model_schedule(policy_service, message):
|
||||
service, catalog, user_root = policy_service
|
||||
original = service.mode_profiles.get_profile("office", "denza_n7")
|
||||
revision = service.mode_profiles.profile_revision(original)
|
||||
plan = normalize_scene_output(
|
||||
_model_output({"driver", "passenger"}, dynamic=True),
|
||||
catalog.scene_composition_context(), dynamic=True,
|
||||
)
|
||||
try:
|
||||
result = _preview(service, message, plan=plan)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
assert result["status"] != "succeeded", "binding an existing mode is not authorization for model-added seats"
|
||||
assert not result.get("mode_profile_draft")
|
||||
assert not _actions(result)
|
||||
assert not result.get("command_plans")
|
||||
assert service.mode_profiles.profile_revision(service.mode_profiles.get_profile("office", "denza_n7")) == revision
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("message", "scope"), [
|
||||
("构建一个办公模式,空调先24度持续30秒,然后24度持续60秒,循环2次", "passenger"),
|
||||
("构建一个办公模式,主驾座椅分两个阶段动态舒缓", "driver"),
|
||||
])
|
||||
def test_existing_mode_binding_keeps_valid_selected_seat_schedule_saveable(policy_service, message, scope):
|
||||
service, catalog, _ = policy_service
|
||||
plan = normalize_scene_output(
|
||||
_model_output({scope}, dynamic=True), catalog.scene_composition_context(), dynamic=True,
|
||||
)
|
||||
result = _preview(service, message, plan=plan)
|
||||
_assert_created(result, {scope})
|
||||
draft = result["mode_profile_draft"]
|
||||
assert draft["existing_mode_id"] == "office"
|
||||
assert draft["existing_mode_revision"]
|
||||
saved = service.save_mode_profile_card(
|
||||
draft["existing_mode_id"], draft["name"], draft["instruction"],
|
||||
draft["source_request_id"], draft["vehicle_id"],
|
||||
)
|
||||
assert saved["status"] == "succeeded"
|
||||
assert saved["mode_profile"]["schedule"] == result["schedule"]
|
||||
executed = service.execute_mode_profile_card(
|
||||
saved["mode_profile"]["id"], execution_mode="dry_run", vehicle_id="denza_n7",
|
||||
)
|
||||
assert executed["status"] == "succeeded"
|
||||
assert _seat_scopes(executed) == {scope}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dynamic", [False, True])
|
||||
def test_default_seat_preference_does_not_force_seat_actions_into_nonseat_scene(policy_service, dynamic):
|
||||
service, catalog, _ = policy_service
|
||||
plan = normalize_scene_output(
|
||||
_model_output(set(), dynamic=dynamic), catalog.scene_composition_context(), dynamic=dynamic,
|
||||
)
|
||||
result = _preview(service, f"构建一个星河恒温{'动态' if dynamic else ''}模式", plan=plan)
|
||||
_assert_created(result, set())
|
||||
assert all(action["domain"] == "climate" for action in _actions(result))
|
||||
|
||||
|
||||
def test_explicit_atomic_mode_requirements_default_unqualified_seat_to_passenger(policy_service):
|
||||
service, _, _ = policy_service
|
||||
result = _preview(service, "构建一个自定义支撑模式,座椅靠背调整到45%,空调24度")
|
||||
_assert_created(result, {"passenger"})
|
||||
assert {_operation_key(action) for action in _actions(result)} == {
|
||||
("seat.set_backrest_recline", "passenger", '{"position_pct": 45}'),
|
||||
# Only the seat default changes; the existing local HVAC zone stays driver.
|
||||
("climate.set_temperature", "driver", '{"temperature_c": 24}'),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dynamic", [False, True])
|
||||
def test_passenger_position_capability_is_not_retargeted_to_driver(policy_service, dynamic):
|
||||
service, catalog, _ = policy_service
|
||||
output = _model_output({"passenger"}, dynamic=dynamic)
|
||||
requests = output["schedule"]["phases"][0]["requests"] if dynamic else output["requests"]
|
||||
requests[0]["target_scope"] = "driver"
|
||||
try:
|
||||
plan = normalize_scene_output(output, catalog.scene_composition_context(), dynamic=dynamic)
|
||||
result = _preview(service, f"构建一个主驾星河释压{'动态' if dynamic else ''}模式", plan=plan)
|
||||
except ValueError:
|
||||
return
|
||||
assert result["status"] != "succeeded"
|
||||
assert not result.get("mode_profile_draft")
|
||||
assert not _actions(result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("message", "scopes"), [
|
||||
("构建一个主驾空调与座椅舒适模式", {"passenger"}),
|
||||
("构建一个主驾阅读灯与座椅舒适模式", {"passenger"}),
|
||||
("构建一个副驾空调与主驾座椅舒适模式", {"driver"}),
|
||||
("构建一个主驾阅读灯与副驾座椅舒适模式", {"passenger"}),
|
||||
])
|
||||
def test_other_device_zones_do_not_select_or_broaden_seat_scope(policy_service, message, scopes):
|
||||
service, catalog, _ = policy_service
|
||||
plan = normalize_scene_output(_model_output(scopes), catalog.scene_composition_context())
|
||||
result = _preview(service, message, plan=plan)
|
||||
_assert_created(result, scopes)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("template_id", "prefix", "scope"), [
|
||||
("reading", "", "passenger"), ("reading", "主驾", "driver"),
|
||||
("seat-dynamic", "", "passenger"), ("seat-dynamic", "主驾", "driver"),
|
||||
("boarding-warmup", "", "passenger"), ("boarding-warmup", "主驾", "driver"),
|
||||
])
|
||||
def test_save_reload_and_simulated_execution_preserve_selected_seat(policy_service, template_id, prefix, scope):
|
||||
service, _, user_root = policy_service
|
||||
template = service.scene_templates.get_template(template_id)
|
||||
preview = _preview(service, f"构建一个{prefix}{template.name}", plan=template.plan())
|
||||
_assert_created(preview, {scope})
|
||||
draft = preview["mode_profile_draft"]
|
||||
saved = service.create_mode_profile_card(
|
||||
draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"],
|
||||
)
|
||||
assert saved["status"] == "succeeded", saved
|
||||
service.mode_profiles = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes", user_root=user_root)
|
||||
executed = service.execute_mode_profile_card(
|
||||
saved["mode_profile"]["id"], conversation_id="conv-seat-policy-replay",
|
||||
execution_mode="dry_run", vehicle_id="denza_n7",
|
||||
)
|
||||
assert executed["status"] == "succeeded", executed.get("validation")
|
||||
assert executed["hardware_execution_allowed"] is False
|
||||
assert _seat_scopes(executed) == {scope}
|
||||
assert sorted(map(_operation_key, _actions(executed))) == sorted(map(_operation_key, _actions(preview)))
|
||||
if preview.get("schedule"):
|
||||
assert executed["schedule"] == preview["schedule"]
|
||||
assert executed["schedule_execution"]["completed"] is True
|
||||
assert all(
|
||||
_seat_scopes(phase["result"]) <= {scope}
|
||||
for phase in executed["schedule_execution"]["phase_results"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("initial_scope", "prefix"), [("driver", "主驾"), ("passenger", "")])
|
||||
def test_unrelated_draft_edit_retains_original_seat_and_explicit_edit_may_extend_it(policy_service, initial_scope, prefix):
|
||||
service, catalog, _ = policy_service
|
||||
name = f"{prefix}星河释压模式"
|
||||
built = _preview(
|
||||
service, f"构建一个{name}",
|
||||
plan=normalize_scene_output(_model_output({initial_scope}), catalog.scene_composition_context()),
|
||||
)
|
||||
_assert_created(built, {initial_scope})
|
||||
draft = built["mode_profile_draft"]
|
||||
revised = _preview(
|
||||
service, f"把{name}的空调温度调整到26度",
|
||||
draft={key: draft[key] for key in ("name", "source_request_id", "vehicle_id")},
|
||||
)
|
||||
_assert_created(revised, {initial_scope})
|
||||
original_seat_ops = {_operation_key(a) for a in _actions(built) if a["domain"] == "seat"}
|
||||
assert {_operation_key(a) for a in _actions(revised) if a["domain"] == "seat"} == original_seat_ops
|
||||
added = "副驾座椅通风调整到1档" if initial_scope == "driver" else "主驾座椅通风调整到低档"
|
||||
draft = revised["mode_profile_draft"]
|
||||
extended = _preview(
|
||||
service, f"把{name}的{added}",
|
||||
draft={key: draft[key] for key in ("name", "source_request_id", "vehicle_id")},
|
||||
)
|
||||
_assert_created(extended, {"driver", "passenger"})
|
||||
assert {_operation_key(a) for a in _actions(extended) if a["domain"] == "seat"} >= original_seat_ops
|
||||
|
||||
|
||||
def _structured_client(output):
|
||||
client = MagicMock()
|
||||
client.available = True
|
||||
client.status = "ready"
|
||||
client.start_thread.return_value = "thread-seat-policy"
|
||||
client.start_turn.side_effect = AssertionError("scene fell back to unconstrained agent")
|
||||
|
||||
def start(thread_id, text, *, application_context, output_schema, timeout=None):
|
||||
Draft202012Validator(output_schema).validate(output)
|
||||
turn = ActiveCodexTurn(thread_id=thread_id, turn_id="turn-seat-policy", events=queue.Queue())
|
||||
turn.events.put({"method": "item/completed", "params": {"item": {
|
||||
"id": "answer", "type": "agentMessage", "text": json.dumps(output, ensure_ascii=False),
|
||||
}}})
|
||||
turn.events.put({"method": "turn/completed", "params": {"turn": {
|
||||
"id": turn.turn_id, "status": "completed", "items": [], "error": None,
|
||||
}}})
|
||||
return turn
|
||||
|
||||
client.start_structured_turn.side_effect = start
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("prefix", "scopes"), [("", {"passenger"}), ("主驾", {"driver"})])
|
||||
@pytest.mark.parametrize("dynamic", [False, True])
|
||||
def test_unregistered_assistant_structured_path_uses_seat_policy(policy_service, prefix, scopes, dynamic):
|
||||
service, catalog, _ = policy_service
|
||||
client = _structured_client(_model_output(scopes, dynamic=dynamic))
|
||||
response, preview, _ = _assistant_preview(
|
||||
service, catalog, f"构建一个{prefix}星河释压{'动态' if dynamic else ''}模式", client=client,
|
||||
)
|
||||
assert response["success"] is True, (response, preview)
|
||||
assert response["route"] == "scene_composer"
|
||||
_assert_created(preview, scopes)
|
||||
client.start_structured_turn.assert_called_once()
|
||||
client.start_turn.assert_not_called()
|
||||
@@ -1237,6 +1237,10 @@ def test_known_mode_name_reuses_repository_profile_even_if_a_plan_is_supplied(
|
||||
) -> None:
|
||||
service, user_root = mode_service
|
||||
nap = service.mode_profiles.get_profile("nap", "denza_n7")
|
||||
selected_operations = [
|
||||
operation for operation in nap.operations
|
||||
if operation.domain != "seat" or operation.target_scope in {"passenger", "front_right"}
|
||||
]
|
||||
result = _preview_without_hardware(
|
||||
service,
|
||||
"构建一个小憩模式",
|
||||
@@ -1249,15 +1253,16 @@ def test_known_mode_name_reuses_repository_profile_even_if_a_plan_is_supplied(
|
||||
draft = _assert_successful_preview(
|
||||
result,
|
||||
name=nap.name,
|
||||
operation_count=len(nap.operations),
|
||||
operation_count=len(selected_operations),
|
||||
)
|
||||
assert draft["generation_source"] == "registered_mode_profile"
|
||||
assert draft["existing_mode_id"] == "nap"
|
||||
expected = {
|
||||
(operation.capability, operation.target_scope): operation.parameters
|
||||
for operation in nap.operations
|
||||
for operation in selected_operations
|
||||
}
|
||||
actual = _action_map(result)
|
||||
assert {target for capability, target in actual if capability.startswith("seat.")} == {"passenger"}
|
||||
for (capability, target), parameters in expected.items():
|
||||
rendered_target = "passenger" if target == "front_right" and capability.startswith("seat.") else target
|
||||
for name, value in parameters.items():
|
||||
|
||||
@@ -0,0 +1,670 @@
|
||||
"""Dynamic scene creation through the real assistant, compiler and saved-mode APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
from copy import deepcopy
|
||||
from threading import Event
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog
|
||||
from cognitive_agent.application.codex_assistant import ActiveCodexTurn, CodexAssistantService
|
||||
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
||||
from cognitive_agent.application.mode_schedule import ModeSchedule
|
||||
from cognitive_agent.application.mode_schedule_service import public_schedule_status
|
||||
from cognitive_agent.application.scene_composer import (
|
||||
SceneCompositionError,
|
||||
build_scene_output_schema,
|
||||
normalize_scene_output,
|
||||
)
|
||||
from cognitive_agent.application.schedule_intent import needs_mode_schedule
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
from cognitive_agent.infrastructure.can.access import HardwareAccessRequiredError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def schedule_service(tmp_path, monkeypatch):
|
||||
for variable in (
|
||||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED", "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED",
|
||||
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED", "COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED",
|
||||
):
|
||||
monkeypatch.setenv(variable, "false")
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service.artifact_root = tmp_path / "artifacts"
|
||||
user_root = tmp_path / "user-modes"
|
||||
service.mode_profiles = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes", user_root=user_root)
|
||||
with (
|
||||
patch.object(service.hardware_execution, "reserve", side_effect=AssertionError("physical vehicle access forbidden")) as reserve,
|
||||
patch.object(service._vehicle_mqtt_transport, "bus", side_effect=AssertionError("physical MQTT access forbidden")),
|
||||
):
|
||||
try:
|
||||
yield service, AssistantCapabilityCatalog(service), user_root
|
||||
finally:
|
||||
service.close()
|
||||
reserve.assert_not_called()
|
||||
|
||||
|
||||
def assistant_preview(service, catalog, message, conversation="conv-schedule-flow", client=None):
|
||||
if client is None:
|
||||
client = MagicMock()
|
||||
client.available = False
|
||||
assistant = CodexAssistantService(client)
|
||||
snapshot = catalog.snapshot(vehicle_id="denza_n7")
|
||||
fast = catalog.fast_answer(message, snapshot)
|
||||
results, events, passed_plans = [], [], []
|
||||
|
||||
def execute(instruction, progress, assistant_plan=None):
|
||||
passed_plans.append(deepcopy(assistant_plan))
|
||||
result = service.preview_native_can(
|
||||
instruction, provider="local", conversation_id=conversation,
|
||||
execution_mode="hardware", vehicle_id="denza_n7",
|
||||
on_commands_validated=progress, assistant_plan=assistant_plan,
|
||||
)
|
||||
results.append(result)
|
||||
return result
|
||||
|
||||
response = assistant.stream(
|
||||
message, conversation_id=conversation, principal="schedule-test", history=[],
|
||||
authoritative_answer=fast.answer if fast else None,
|
||||
emit=lambda event: events.append(event) or True,
|
||||
execute_control=execute, capability_snapshot=snapshot,
|
||||
capability_context=catalog.compact_context(snapshot),
|
||||
scene_capability_context=catalog.scene_composition_context(snapshot),
|
||||
control_terms=catalog.control_terms(snapshot),
|
||||
)
|
||||
assert len(results) == 1, (response, results)
|
||||
return response, results[0], events, passed_plans, client
|
||||
|
||||
|
||||
def create_saved(service, catalog, message="构建一个上车预热的功能模式"):
|
||||
response, preview, _, _, _ = assistant_preview(service, catalog, message)
|
||||
assert response["success"] is True, (response, preview)
|
||||
draft = preview["mode_profile_draft"]
|
||||
saved = service.create_mode_profile_card(
|
||||
draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"],
|
||||
)
|
||||
assert saved["status"] == "succeeded"
|
||||
return preview, saved["mode_profile"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("message", "template_id", "count", "repeats", "elapsed"), [
|
||||
("构建一个座椅的动态模式", "seat-dynamic", 4, 5, 300000),
|
||||
("构建一个上车预热的功能模式", "boarding-warmup", 7, 1, 420000),
|
||||
("帮我创建一个座椅动态模式吧", "seat-dynamic", 4, 5, 300000),
|
||||
("我想构建一个上车预热模式并预览", "boarding-warmup", 7, 1, 420000),
|
||||
("构建一个上车暖舱模式", "boarding-warmup", 7, 1, 420000),
|
||||
("构建一个空调预热模式", "boarding-warmup", 7, 1, 420000),
|
||||
("构建一个动态座椅模式", "seat-dynamic", 4, 5, 300000),
|
||||
("构建一个座椅交替放松模式", "seat-dynamic", 4, 5, 300000),
|
||||
])
|
||||
def test_user_temporal_requests_create_complete_multistage_template_without_model(
|
||||
schedule_service, message, template_id, count, repeats, elapsed,
|
||||
):
|
||||
service, catalog, user_root = schedule_service
|
||||
response, preview, events, plans, client = assistant_preview(service, catalog, message)
|
||||
assert response["success"] is True, (response, preview)
|
||||
assert response["route"] == "scene_template"
|
||||
assert preview["status"] == "succeeded"
|
||||
assert preview["execution_mode"] == "dry_run"
|
||||
assert preview["hardware_execution_allowed"] is False
|
||||
assert preview["orchestration_route"] == "mode_profile_create_preview"
|
||||
draft = preview["mode_profile_draft"]
|
||||
assert draft["source_template_id"] == template_id
|
||||
assert draft["generation_source"] == "scene_template_copy"
|
||||
assert draft["operation_count"] == count
|
||||
expected_schedule = deepcopy(plans[0]["schedule"])
|
||||
for phase in expected_schedule["phases"]:
|
||||
phase["operations"] = [
|
||||
op for op in phase["operations"]
|
||||
if op["domain"] != "seat" or op["target_scope"] == "passenger"
|
||||
]
|
||||
assert draft["schedule"] == preview["schedule"] == expected_schedule
|
||||
schedule = ModeSchedule.from_dict(preview["schedule"])
|
||||
assert schedule.repeat_count == repeats
|
||||
assert schedule.total_duration_ms == elapsed
|
||||
actions = preview["intent_ir"]["spec"]["actions"]
|
||||
assert len(actions) == count
|
||||
assert len({action["id"] for action in actions}) == count
|
||||
assert {item["phase_id"] for item in preview["schedule_phase_previews"]} == {phase.id for phase in schedule.phases}
|
||||
assert len(preview["schedule_phase_previews"]) == 2
|
||||
assert all(item["status"] == "succeeded" for item in preview["schedule_phase_previews"])
|
||||
assert all(any(key.startswith(phase.id + "-") for key in preview["command_plans"]) for phase in schedule.phases)
|
||||
assert {
|
||||
op["target_scope"] for phase in schedule.phases for op in phase.operations
|
||||
if op["domain"] == "seat"
|
||||
} == {"passenger"}
|
||||
assert events
|
||||
client.start_thread.assert_not_called()
|
||||
client.start_turn.assert_not_called()
|
||||
client.start_structured_turn.assert_not_called()
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", ["构建一个座椅的动态模式", "构建一个上车预热的功能模式"])
|
||||
def test_draft_trial_preserves_all_stages_repeats_and_virtual_time_without_thread(schedule_service, message):
|
||||
service, catalog, _ = schedule_service
|
||||
_, preview, _, _, _ = assistant_preview(service, catalog, message)
|
||||
draft = preview["mode_profile_draft"]
|
||||
with patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("dry-run spawned worker")):
|
||||
trial = service.trial_mode_profile_draft(
|
||||
draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"],
|
||||
)
|
||||
assert trial["status"] == "succeeded"
|
||||
assert trial["hardware_execution_allowed"] is False
|
||||
assert trial["schedule"] == preview["schedule"]
|
||||
execution = trial["schedule_execution"]
|
||||
schedule = ModeSchedule.from_dict(preview["schedule"])
|
||||
assert execution["completed"] is True
|
||||
assert execution["virtual_elapsed_ms"] == schedule.total_duration_ms
|
||||
assert execution["executed_phases"] == len(schedule.phases) * schedule.repeat_count
|
||||
assert [step["phase_id"] for step in execution["phase_results"]] == [phase.id for phase in schedule.phases] * schedule.repeat_count
|
||||
assert len(trial["intent_ir"]["spec"]["actions"]) == schedule.operation_count
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", ["构建一个座椅的动态模式", "构建一个上车预热的功能模式"])
|
||||
def test_save_reload_and_direct_execute_keep_temporal_semantics(schedule_service, message):
|
||||
service, catalog, user_root = schedule_service
|
||||
preview, public_profile = create_saved(service, catalog, message)
|
||||
assert public_profile["schedule"] == preview["schedule"]
|
||||
assert public_profile["behavior"] == "cyclic"
|
||||
mode_id = public_profile["id"]
|
||||
service.mode_profiles = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes", user_root=user_root)
|
||||
reloaded = service.mode_profiles.get_profile(mode_id, "denza_n7")
|
||||
assert reloaded.schedule.to_dict() == preview["schedule"]
|
||||
with patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("dry-run spawned worker")):
|
||||
executed = service.execute_mode_profile_card(
|
||||
mode_id, conversation_id="conv-schedule-execute", execution_mode="dry_run", vehicle_id="denza_n7",
|
||||
)
|
||||
assert executed["status"] == "succeeded", executed
|
||||
assert executed["orchestration_route"] == "mode_schedule_execute"
|
||||
assert executed["execution_mode"] == "dry_run"
|
||||
assert executed["hardware_execution_allowed"] is False
|
||||
assert "mode_profile_draft" not in executed
|
||||
execution = executed["schedule_execution"]
|
||||
assert execution["completed"] is True
|
||||
assert execution["virtual_elapsed_ms"] == reloaded.schedule.total_duration_ms
|
||||
assert len(execution["phase_results"]) == len(reloaded.schedule.phases) * reloaded.schedule.repeat_count
|
||||
expected_phases = list(reloaded.schedule.phases) * reloaded.schedule.repeat_count
|
||||
for expected, actual in zip(expected_phases, execution["phase_results"], strict=True):
|
||||
phase_result = actual["result"]
|
||||
assert phase_result["status"] == "succeeded"
|
||||
assert phase_result["execution_mode"] == "dry_run"
|
||||
actual_actions = phase_result["intent_ir"]["spec"]["actions"]
|
||||
assert len(actual_actions) == len(expected.operations)
|
||||
expected_params = sorted(json.dumps(op["parameters"], sort_keys=True) for op in expected.operations)
|
||||
actual_params = sorted(json.dumps(op["parameters"], sort_keys=True) for op in actual_actions)
|
||||
assert actual_params == expected_params
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
|
||||
|
||||
|
||||
def test_natural_language_enter_saved_schedule_runs_timeline_not_flat_actions(schedule_service):
|
||||
service, catalog, _ = schedule_service
|
||||
preview, saved = create_saved(service, catalog)
|
||||
result = service.process(
|
||||
f"进入{saved['name']}", provider="local", conversation_id="conv-schedule-natural-enter",
|
||||
execution_mode="dry_run", vehicle_id="denza_n7",
|
||||
)
|
||||
assert result["status"] == "succeeded", result
|
||||
assert result["orchestration_route"] == "mode_schedule_execute"
|
||||
assert result["schedule"] == preview["schedule"]
|
||||
temperatures = []
|
||||
for phase in result["schedule_execution"]["phase_results"]:
|
||||
temperatures.extend(action["parameters"]["temperature_c"] for action in phase["result"]["intent_ir"]["spec"]["actions"] if action["capability"] == "climate.set_temperature")
|
||||
assert temperatures == [28, 24]
|
||||
|
||||
|
||||
def test_missing_hardware_lease_rejects_before_any_worker_or_physical_access(schedule_service):
|
||||
service, catalog, _ = schedule_service
|
||||
_, saved = create_saved(service, catalog)
|
||||
with (
|
||||
patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("unauthorized worker started")),
|
||||
pytest.raises(HardwareAccessRequiredError),
|
||||
):
|
||||
service.execute_mode_profile_card(
|
||||
saved["id"], execution_mode="hardware", vehicle_id="denza_n7",
|
||||
conversation_id="conv-schedule-unauthorized", hardware_access_bypass=False,
|
||||
hardware_client_id="hwc-schedule-unauthorized",
|
||||
)
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("field", "value"), [
|
||||
("name", "其他模式"), ("instruction", "直接执行危险指令"),
|
||||
("vehicle_id", "other_vehicle"), ("source_request_id", "../../result"),
|
||||
])
|
||||
def test_client_cannot_rebind_schedule_draft_trial(schedule_service, field, value):
|
||||
service, catalog, user_root = schedule_service
|
||||
_, preview, _, _, _ = assistant_preview(service, catalog, "构建一个上车预热的功能模式")
|
||||
draft = preview["mode_profile_draft"]
|
||||
arguments = {key: draft[key] for key in ("name", "instruction", "source_request_id", "vehicle_id")}
|
||||
arguments[field] = value
|
||||
with pytest.raises(ValueError):
|
||||
service.trial_mode_profile_draft(**arguments)
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation", ["capability", "duration", "temperature", "target"])
|
||||
def test_tampered_template_schedule_never_creates_or_executes_draft(schedule_service, mutation):
|
||||
service, catalog, user_root = schedule_service
|
||||
template = next(item for item in catalog.snapshot(vehicle_id="denza_n7")["scene_templates"] if item["id"] == "boarding-warmup")
|
||||
plan = deepcopy(template["plan"])
|
||||
phase = plan["schedule"]["phases"][0]
|
||||
if mutation == "capability":
|
||||
phase["operations"][0]["capability"] = "door.set_vehicle_lock"
|
||||
elif mutation == "duration":
|
||||
phase["duration_ms"] = 1
|
||||
elif mutation == "temperature":
|
||||
phase["operations"][1]["parameters"]["temperature_c"] = 99
|
||||
else:
|
||||
phase["operations"][3]["target_scope"] = "passenger"
|
||||
try:
|
||||
result = service.preview_native_can(
|
||||
"构建一个上车预热的功能模式", provider="local", conversation_id="conv-schedule-tamper",
|
||||
execution_mode="hardware", vehicle_id="denza_n7", assistant_plan=plan,
|
||||
)
|
||||
except ValueError:
|
||||
result = None
|
||||
if result is not None:
|
||||
assert result["status"] != "succeeded"
|
||||
assert "mode_profile_draft" not in result
|
||||
assert result.get("hardware_execution_allowed") is not True
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
|
||||
|
||||
|
||||
def test_invalid_later_saved_phase_is_revalidated_before_any_phase_executes(schedule_service):
|
||||
service, catalog, _ = schedule_service
|
||||
_, saved = create_saved(service, catalog)
|
||||
profile = service.mode_profiles.get_profile(saved["id"], "denza_n7")
|
||||
# Represents a stale or externally edited user profile, not a trusted runtime
|
||||
# capability change. Its structure remains valid, but the target is unsafe.
|
||||
profile.schedule.phases[1].operations[0]["parameters"]["temperature_c"] = 99
|
||||
with patch.object(service.mode_schedule_runner, "simulate", side_effect=AssertionError("invalid later phase reached executor")):
|
||||
result = service.execute_mode_profile_card(
|
||||
saved["id"], conversation_id="conv-schedule-stale-capability",
|
||||
execution_mode="dry_run", vehicle_id="denza_n7",
|
||||
)
|
||||
assert result["status"] == "validation_failed", result
|
||||
assert result["mode_creation_failure"]["code"] == "MODE_SCHEDULE_PHASE_INVALID"
|
||||
assert "mode_profile_draft" not in result
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
|
||||
|
||||
|
||||
def test_execution_failure_after_validated_first_phase_prevents_later_repeats(schedule_service):
|
||||
service, catalog, _ = schedule_service
|
||||
_, saved = create_saved(service, catalog, "构建一个主驾和副驾的座椅动态模式")
|
||||
preview_method = service._preview_mode_schedule
|
||||
virtual_method = service.native_can_runtime.execute_virtual
|
||||
execution_started = False
|
||||
executed_massage_modes = []
|
||||
|
||||
def validate_then_arm(*args, **kwargs):
|
||||
nonlocal execution_started
|
||||
result = preview_method(*args, **kwargs)
|
||||
execution_started = True
|
||||
return result
|
||||
|
||||
def fault_on_second_phase(command_plan, function_config):
|
||||
if execution_started:
|
||||
for op in function_config["spec"]["operations"]:
|
||||
if op["capability"] == "seat.set_driver_massage_native":
|
||||
executed_massage_modes.append(op["parameters"]["mode"])
|
||||
if op["parameters"]["mode"] == "slack":
|
||||
raise RuntimeError("injected lower-level actuator failure")
|
||||
return virtual_method(command_plan, function_config)
|
||||
|
||||
with (
|
||||
patch.object(service, "_preview_mode_schedule", side_effect=validate_then_arm),
|
||||
patch.object(service.native_can_runtime, "execute_virtual", side_effect=fault_on_second_phase),
|
||||
):
|
||||
result = service.execute_mode_profile_card(
|
||||
saved["id"], conversation_id="conv-schedule-injected-failure",
|
||||
execution_mode="dry_run", vehicle_id="denza_n7",
|
||||
)
|
||||
execution = result["schedule_execution"]
|
||||
assert result["status"] == "failed", result
|
||||
assert execution["completed"] is False
|
||||
assert execution["error"]
|
||||
assert execution["executed_phases"] == 1
|
||||
assert execution["virtual_elapsed_ms"] == 30000
|
||||
assert execution["repeat_index"] == 0
|
||||
assert len(execution["phase_results"]) == 2
|
||||
assert executed_massage_modes == ["relax", "slack"]
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
|
||||
|
||||
def structured_schedule():
|
||||
return {
|
||||
"requests": [], "ambiguities": [], "confidence": 0.96,
|
||||
"schedule": {
|
||||
"repeat_count": 2,
|
||||
"phases": [
|
||||
{"id": "warm", "name": "暖舱", "duration_ms": 30000,
|
||||
"requests": [{"capability": "climate.set_temperature", "target_scope": "vehicle", "parameters": {"temperature_c": 28}}]},
|
||||
{"id": "settle", "name": "恒温", "duration_ms": 60000,
|
||||
"requests": [{"capability": "climate.set_temperature", "target_scope": "vehicle", "parameters": {"temperature_c": 24}}]},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def structured_client(output):
|
||||
client = MagicMock()
|
||||
client.available = True
|
||||
client.status = "ready"
|
||||
client.start_thread.return_value = "thread-schedule-test"
|
||||
client.start_turn.side_effect = AssertionError("dynamic creation fell back to unstructured agent")
|
||||
|
||||
def start(thread_id, text, *, application_context, output_schema, timeout=None):
|
||||
Draft202012Validator(output_schema).validate(output)
|
||||
turn = ActiveCodexTurn(thread_id=thread_id, turn_id="turn-schedule-test", events=queue.Queue())
|
||||
turn.events.put({"method": "item/completed", "params": {"item": {"id": "answer", "type": "agentMessage", "text": json.dumps(output, ensure_ascii=False)}}})
|
||||
turn.events.put({"method": "turn/completed", "params": {"turn": {"id": turn.turn_id, "status": "completed", "items": [], "error": None}}})
|
||||
return turn
|
||||
|
||||
client.start_structured_turn.side_effect = start
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", [
|
||||
"构建一个星云动态模式,空调先28度持续30秒,然后24度持续60秒,循环2次",
|
||||
"构建一个阅读模式,空调先28度持续30秒,然后24度持续60秒,循环2次",
|
||||
"构建一个上车预热的功能模式,空调先28度持续30秒,然后24度持续60秒,循环2次",
|
||||
])
|
||||
def test_explicit_temporal_parameters_use_structured_composition_not_static_template(schedule_service, message):
|
||||
service, catalog, user_root = schedule_service
|
||||
output = structured_schedule()
|
||||
client = structured_client(output)
|
||||
response, preview, _, plans, _ = assistant_preview(service, catalog, message, client=client)
|
||||
assert response["route"] == "scene_composer", response
|
||||
assert response["success"] is True, (response, preview)
|
||||
assert preview["mode_profile_draft"]["generation_source"] != "scene_template_copy"
|
||||
client.start_structured_turn.assert_called_once()
|
||||
client.start_turn.assert_not_called()
|
||||
assert "schedule" in client.start_structured_turn.call_args.kwargs["output_schema"]["properties"]
|
||||
assert preview["schedule"] == plans[0]["schedule"]
|
||||
assert preview["schedule"]["repeat_count"] == 2
|
||||
assert [phase["duration_ms"] for phase in preview["schedule"]["phases"]] == [30000, 60000]
|
||||
assert [phase["operations"][0]["parameters"]["temperature_c"] for phase in preview["schedule"]["phases"]] == [28, 24]
|
||||
assert ModeSchedule.from_dict(preview["schedule"]).total_duration_ms == 180000
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
|
||||
|
||||
def test_dynamic_schema_normalization_preserves_complete_phase_contract(schedule_service):
|
||||
_, catalog, _ = schedule_service
|
||||
context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7"))
|
||||
output = structured_schedule()
|
||||
schema = build_scene_output_schema(context, dynamic=True)
|
||||
Draft202012Validator(schema).validate(output)
|
||||
plan = normalize_scene_output(output, context, dynamic=True)
|
||||
assert plan["ambiguities"] == []
|
||||
schedule = ModeSchedule.from_dict(plan["schedule"])
|
||||
assert schedule.total_duration_ms == 180000
|
||||
assert schedule.operation_count == 2
|
||||
assert schedule.phases[0].operations[0]["domain"] == "climate"
|
||||
assert schedule.phases[1].operations[0]["parameters"] == {"temperature_c": 24}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation", [
|
||||
"static-root", "unknown-root", "unknown-schedule", "unknown-phase", "unknown-capability",
|
||||
"duplicate-phase", "duplicate-operation", "no-phases", "empty-phase", "bool-duration",
|
||||
"bool-repeat", "long-duration", "long-total", "nan-parameter", "motion-capability",
|
||||
])
|
||||
def test_invalid_structured_dynamic_plans_fail_closed_during_normalization(schedule_service, mutation):
|
||||
_, catalog, _ = schedule_service
|
||||
context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7"))
|
||||
output = structured_schedule()
|
||||
schedule = output["schedule"]
|
||||
phase = schedule["phases"][0]
|
||||
if mutation == "static-root":
|
||||
output["requests"] = deepcopy(phase["requests"])
|
||||
elif mutation == "unknown-root":
|
||||
output["execute_now"] = True
|
||||
elif mutation == "unknown-schedule":
|
||||
schedule["unbounded"] = True
|
||||
elif mutation == "unknown-phase":
|
||||
phase["skip_safety"] = True
|
||||
elif mutation == "unknown-capability":
|
||||
phase["requests"][0]["capability"] = "unknown.actuate"
|
||||
elif mutation == "motion-capability":
|
||||
phase["requests"][0] = {"capability": "seat.set_driver_axis_native", "target_scope": "driver", "parameters": {"duration_ms": 1000, "axis": "backrest", "direction": "backward"}}
|
||||
elif mutation == "duplicate-phase":
|
||||
schedule["phases"][1]["id"] = phase["id"]
|
||||
elif mutation == "duplicate-operation":
|
||||
phase["requests"].append(deepcopy(phase["requests"][0]))
|
||||
elif mutation == "no-phases":
|
||||
schedule["phases"] = []
|
||||
elif mutation == "empty-phase":
|
||||
phase["requests"] = []
|
||||
elif mutation == "bool-duration":
|
||||
phase["duration_ms"] = True
|
||||
elif mutation == "bool-repeat":
|
||||
schedule["repeat_count"] = True
|
||||
elif mutation == "long-duration":
|
||||
phase["duration_ms"] = 900001
|
||||
elif mutation == "long-total":
|
||||
schedule["repeat_count"] = 20
|
||||
phase["duration_ms"] = 900000
|
||||
elif mutation == "nan-parameter":
|
||||
phase["requests"][0]["parameters"]["temperature_c"] = float("nan")
|
||||
with pytest.raises(SceneCompositionError):
|
||||
normalize_scene_output(output, context, dynamic=True)
|
||||
|
||||
|
||||
def test_dynamic_ambiguity_produces_no_schedule_or_executable_actions(schedule_service):
|
||||
_, catalog, _ = schedule_service
|
||||
context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7"))
|
||||
output = {"requests": [], "ambiguities": ["当前未开放该动态动作,请指定其他设备。"], "confidence": 0.2, "schedule": None}
|
||||
Draft202012Validator(build_scene_output_schema(context, dynamic=True)).validate(output)
|
||||
plan = normalize_scene_output(output, context, dynamic=True)
|
||||
assert not plan["requests"]
|
||||
assert "schedule" not in plan
|
||||
assert plan["ambiguities"]
|
||||
|
||||
|
||||
def test_model_schedule_with_unresolved_requirement_never_generates_successful_draft(schedule_service):
|
||||
service, catalog, user_root = schedule_service
|
||||
context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7"))
|
||||
output = structured_schedule()
|
||||
output["ambiguities"] = ["不能完成要求的主驾座椅绝对位置控制。"]
|
||||
plan = normalize_scene_output(output, context, dynamic=True)
|
||||
assert "metadata" not in plan
|
||||
with pytest.raises(ValueError, match="未解决"):
|
||||
service.preview_native_can(
|
||||
"构建一个流光动态模式", provider="local", conversation_id="conv-schedule-ambiguous",
|
||||
execution_mode="hardware", vehicle_id="denza_n7", assistant_plan=plan,
|
||||
)
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("seat", "allowed_targets"), [
|
||||
("主驾", {"driver", "front_left"}), ("副驾", {"passenger", "front_right"}),
|
||||
])
|
||||
def test_single_seat_dynamic_execution_cannot_expand_to_both_seats(schedule_service, seat, allowed_targets):
|
||||
service, catalog, _ = schedule_service
|
||||
_, saved = create_saved(service, catalog, "构建一个主驾和副驾的座椅动态模式")
|
||||
result = service.process(
|
||||
f"只让{seat}进入{saved['name']}", provider="local", conversation_id="conv-schedule-single-seat",
|
||||
execution_mode="dry_run", vehicle_id="denza_n7",
|
||||
)
|
||||
if result["status"] == "succeeded":
|
||||
assert result["orchestration_route"] == "mode_schedule_execute"
|
||||
execution = result["schedule_execution"]
|
||||
for phase in execution["phase_results"]:
|
||||
actions = phase["result"]["intent_ir"]["spec"]["actions"]
|
||||
seat_targets = {action["target"].get("instance") or action["target"].get("zone") for action in actions if action["domain"] == "seat"}
|
||||
assert seat_targets and seat_targets <= allowed_targets
|
||||
else:
|
||||
assert result["status"] in {"validation_failed", "needs_clarification", "needs_confirmation", "failed"}
|
||||
assert not result.get("schedule_execution", {}).get("phase_results")
|
||||
assert result.get("hardware_execution_allowed") is not True
|
||||
|
||||
|
||||
def start_waiting_schedule(service):
|
||||
"""Run only a no-device test callback, holding until the tested stop request."""
|
||||
calls = []
|
||||
entered = Event()
|
||||
schedule = ModeSchedule.from_dict({
|
||||
"schema": "cognitive-agent-mode-schedule/v1", "repeat_count": 1,
|
||||
"phases": [
|
||||
{"id": phase_id, "name": phase_id, "duration_ms": 900000,
|
||||
"operations": [{"domain": "climate", "capability": "climate.set_temperature",
|
||||
"target_scope": "vehicle", "operation": "set",
|
||||
"parameters": {"temperature_c": temperature}, "reason": "test only"}]}
|
||||
for phase_id, temperature in (("first", 28), ("second", 24))
|
||||
],
|
||||
})
|
||||
|
||||
def execute(phase):
|
||||
calls.append(phase.id)
|
||||
entered.set()
|
||||
return {"status": "succeeded"}
|
||||
|
||||
service.mode_schedule_runner.start("denza_n7", schedule, execute, session_id="conv-stop-status")
|
||||
assert entered.wait(2)
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "running"
|
||||
return calls
|
||||
|
||||
|
||||
def assistant_control(catalog, message, execute):
|
||||
client = MagicMock()
|
||||
client.available = False
|
||||
assistant = CodexAssistantService(client)
|
||||
snapshot = catalog.snapshot(vehicle_id="denza_n7")
|
||||
events = []
|
||||
response = assistant.stream(
|
||||
message, conversation_id="conv-stop-status", principal="schedule-status-test", history=[],
|
||||
emit=lambda event: events.append(event) or True,
|
||||
execute_control=execute, capability_snapshot=snapshot,
|
||||
capability_context=catalog.compact_context(snapshot),
|
||||
scene_capability_context=catalog.scene_composition_context(snapshot),
|
||||
control_terms=catalog.control_terms(snapshot),
|
||||
)
|
||||
client.start_thread.assert_not_called()
|
||||
client.start_structured_turn.assert_not_called()
|
||||
client.start_turn.assert_not_called()
|
||||
return response, events
|
||||
|
||||
|
||||
def test_dry_run_stop_does_not_cancel_a_running_vehicle_schedule(schedule_service):
|
||||
service, _, _ = schedule_service
|
||||
calls = start_waiting_schedule(service)
|
||||
result = service.process(
|
||||
"停止动态模式", provider="local", conversation_id="conv-stop-status",
|
||||
execution_mode="dry_run", vehicle_id="denza_n7",
|
||||
)
|
||||
assert result["status"] == "succeeded"
|
||||
assert result["schedule_execution"]["status"] == "simulated"
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "running"
|
||||
assert calls == ["first"]
|
||||
persisted = json.loads((service.artifact_root / result["request_id"] / "result.json").read_text(encoding="utf-8"))
|
||||
assert persisted["orchestration_route"] == "mode_schedule_stop"
|
||||
assert persisted["trace_events"] == []
|
||||
|
||||
|
||||
def test_hardware_stop_cancels_future_stages_and_persists_the_result(schedule_service):
|
||||
service, _, _ = schedule_service
|
||||
calls = start_waiting_schedule(service)
|
||||
result = service.process(
|
||||
"停止动态模式", provider="local", conversation_id="conv-stop-status",
|
||||
execution_mode="hardware", vehicle_id="denza_n7",
|
||||
)
|
||||
assert result["status"] == "succeeded"
|
||||
assert result["schedule_execution"]["status"] == "cancelled"
|
||||
assert result["schedule_execution"]["completed"] is False
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "cancelled"
|
||||
assert service.mode_schedule_runner.close(timeout=1)
|
||||
assert calls == ["first"]
|
||||
persisted = json.loads((service.artifact_root / result["request_id"] / "result.json").read_text(encoding="utf-8"))
|
||||
assert persisted["orchestration_route"] == "mode_schedule_stop"
|
||||
assert persisted["schedule_execution"]["status"] == "cancelled"
|
||||
assert persisted["trace_events"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("execution_mode", "action_state", "runner_state"), [
|
||||
("hardware", "cancelled", "cancelled"), ("dry_run", "simulated", "running"),
|
||||
])
|
||||
def test_assistant_stop_is_zero_model_success_and_preserves_execution_policy(
|
||||
schedule_service, execution_mode, action_state, runner_state,
|
||||
):
|
||||
service, catalog, _ = schedule_service
|
||||
calls = start_waiting_schedule(service)
|
||||
|
||||
def execute(message, progress):
|
||||
return service.process(
|
||||
message, provider="local", conversation_id="conv-stop-status",
|
||||
execution_mode=execution_mode, vehicle_id="denza_n7",
|
||||
)
|
||||
|
||||
response, events = assistant_control(catalog, "停止动态模式", execute)
|
||||
assert response["success"] is True, response
|
||||
assert response["route"] == "vehicle_control"
|
||||
action = next(event for event in events if event["type"] == "action.completed")
|
||||
assert action["success"] is True
|
||||
assert action["state"] == action_state
|
||||
assert action["payload"]["orchestration_route"] == "mode_schedule_stop"
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == runner_state
|
||||
assert calls == ["first"]
|
||||
assert "已完成" not in response["answer"]
|
||||
assert "未改变正在运行" in response["answer"] if execution_mode == "dry_run" else "已停止" in response["answer"]
|
||||
|
||||
|
||||
def test_assistant_running_schedule_is_accepted_without_claiming_all_phases_completed(schedule_service):
|
||||
service, catalog, _ = schedule_service
|
||||
preview, profile = create_saved(service, catalog)
|
||||
start_waiting_schedule(service)
|
||||
state = service.mode_schedule_runner.status("denza_n7")
|
||||
state["phase_results"] = [{"command_frames": ["sensitive-can-frame"]}]
|
||||
|
||||
def execute(message, progress):
|
||||
return {
|
||||
"status": "running", "execution_mode": "hardware", "hardware_execution_allowed": True,
|
||||
"orchestration_route": "mode_schedule_execute", "schedule_execution": state,
|
||||
"mode_profile": profile, "matched_modes": [profile], "schedule": preview["schedule"],
|
||||
}
|
||||
|
||||
response, events = assistant_control(catalog, f"进入{profile['name']}", execute)
|
||||
assert response["success"] is True, response
|
||||
action = next(event for event in events if event["type"] == "action.completed")
|
||||
assert action["success"] is True
|
||||
assert action["state"] == "running"
|
||||
assert action["payload"]["status"] == "running"
|
||||
assert action["payload"]["schedule_execution"]["completed"] is False
|
||||
assert "phase_results" not in action["payload"]["schedule_execution"]
|
||||
assert "sensitive-can-frame" not in json.dumps(events, ensure_ascii=False)
|
||||
assert "尚未完成所有阶段" in action["display_text"]
|
||||
assert "尚未完成所有阶段" in response["answer"]
|
||||
completed = next(event for event in events if event["type"] == "work.completed")
|
||||
assert completed["state"] == "running"
|
||||
assert "已完成" not in completed["display_text"]
|
||||
|
||||
|
||||
def test_public_schedule_status_exposes_only_lifecycle_and_redacts_raw_error():
|
||||
state = {
|
||||
"run_id": "run-public", "vehicle_id": "denza_n7", "status": "failed",
|
||||
"phase_index": 1, "repeat_index": 0, "completed": False,
|
||||
"error": "secret-token /private/path/driver.dll CAN payload 123456",
|
||||
"phase_results": [{"command_plan": {"data_hex": "deadbeef"}}],
|
||||
"raw_error": {"token": "secret-token"}, "command_plans": {"internal": "data"},
|
||||
"phase_count": {"nested": "unexpected"},
|
||||
}
|
||||
public = public_schedule_status(state)
|
||||
assert public["status"] == "failed"
|
||||
assert public["phase_index"] == 1
|
||||
assert public["completed"] is False
|
||||
assert public["error"] and public["error"] != state["error"]
|
||||
assert set(public) == {"run_id", "vehicle_id", "status", "phase_index", "repeat_index", "completed", "error"}
|
||||
for sensitive in ("secret-token", "driver.dll", "deadbeef", "123456"):
|
||||
assert sensitive not in json.dumps(public, ensure_ascii=False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", ["外循环通风模式", "内循环模式"])
|
||||
def test_hvac_circulation_state_is_not_a_temporal_schedule(message):
|
||||
assert needs_mode_schedule(message) is False
|
||||
@@ -0,0 +1,278 @@
|
||||
"""Natural-language edits cannot flatten or silently broaden dynamic phases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
||||
from cognitive_agent.application.mode_schedule import ModeSchedule
|
||||
from cognitive_agent.application.mode_schedule_edit import edit_schedule
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(tmp_path):
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false",
|
||||
},
|
||||
):
|
||||
instance = SeatDemoService(REPOSITORY_ROOT)
|
||||
instance.artifact_root = tmp_path / "runs"
|
||||
instance.mode_profiles = ModeProfileRegistry(
|
||||
REPOSITORY_ROOT / "config" / "modes",
|
||||
user_root=tmp_path / "user-modes",
|
||||
)
|
||||
try:
|
||||
with patch.object(
|
||||
instance.hardware_execution,
|
||||
"reserve",
|
||||
side_effect=AssertionError("schedule edit reached hardware"),
|
||||
):
|
||||
yield instance
|
||||
finally:
|
||||
instance.close()
|
||||
|
||||
|
||||
def _build(service):
|
||||
template = service.scene_templates.get_template("boarding-warmup")
|
||||
result = service.preview_native_can(
|
||||
"构建一个上车预热模式",
|
||||
provider="local",
|
||||
conversation_id="conv-schedule-edit",
|
||||
execution_mode="hardware",
|
||||
vehicle_id="denza_n7",
|
||||
assistant_plan=template.plan(),
|
||||
)
|
||||
assert result["status"] == "succeeded", result.get("validation")
|
||||
return result
|
||||
|
||||
|
||||
def _edit(service, built, change):
|
||||
draft = built["mode_profile_draft"]
|
||||
reference = {key: draft[key] for key in ("name", "source_request_id", "vehicle_id")}
|
||||
return service.preview_native_can(
|
||||
f"把上车预热模式的{change}",
|
||||
provider="local",
|
||||
conversation_id="conv-schedule-edit",
|
||||
execution_mode="hardware",
|
||||
vehicle_id="denza_n7",
|
||||
mode_profile_draft_context=reference,
|
||||
)
|
||||
|
||||
|
||||
def _parameters(schedule, index, capability, target="vehicle"):
|
||||
return next(
|
||||
op["parameters"]
|
||||
for op in schedule["phases"][index]["operations"]
|
||||
if op["capability"] == capability and op["target_scope"] == target
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"change",
|
||||
(
|
||||
"第二阶段空调温度调整到26度",
|
||||
"第2个阶段的空调温度变成26度",
|
||||
"舒适恒温阶段空调温度改为26度",
|
||||
),
|
||||
)
|
||||
def test_draft_temperature_edit_preserves_first_phase_and_timing(service, change):
|
||||
built = _build(service)
|
||||
original = deepcopy(built["schedule"])
|
||||
revised = _edit(service, built, change)
|
||||
assert revised["status"] == "succeeded", revised.get("mode_creation_failure")
|
||||
expected = deepcopy(original)
|
||||
_parameters(expected, 1, "climate.set_temperature")["temperature_c"] = 26
|
||||
assert revised["schedule"] == expected
|
||||
assert built["schedule"] == original
|
||||
assert revised["mode_profile_draft"]["schedule"] == expected
|
||||
assert revised["mode_profile_draft"]["draft_revision"] == 2
|
||||
assert revised["mode_profile_draft"]["revises_source_request_id"] == built["request_id"]
|
||||
assert revised["execution_mode"] == "dry_run"
|
||||
assert revised["hardware_execution_allowed"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("change", "expected_hold", "expected_repeat"),
|
||||
(
|
||||
("第二阶段持续时间调整为3分钟", 180000, 1),
|
||||
("第二阶段保持30秒", 30000, 1),
|
||||
("第二阶段时长改成2.5分钟", 150000, 1),
|
||||
("循环次数改成3次", 300000, 3),
|
||||
("重复两次", 300000, 2),
|
||||
),
|
||||
)
|
||||
def test_draft_time_and_repeat_edits_keep_operations(
|
||||
service, change, expected_hold, expected_repeat
|
||||
):
|
||||
built = _build(service)
|
||||
revised = _edit(service, built, change)
|
||||
assert revised["status"] == "succeeded", revised.get("mode_creation_failure")
|
||||
assert revised["schedule"]["phases"][1]["duration_ms"] == expected_hold
|
||||
assert revised["schedule"]["repeat_count"] == expected_repeat
|
||||
for index in (0, 1):
|
||||
assert (
|
||||
revised["schedule"]["phases"][index]["operations"]
|
||||
== built["schedule"]["phases"][index]["operations"]
|
||||
)
|
||||
|
||||
|
||||
def test_all_phases_temperature_edit_is_explicit_and_preserves_all_other_operations(service):
|
||||
built = _build(service)
|
||||
revised = _edit(service, built, "全部阶段空调温度调整到25度")
|
||||
assert revised["status"] == "succeeded", revised.get("mode_creation_failure")
|
||||
expected = deepcopy(built["schedule"])
|
||||
for index in (0, 1):
|
||||
_parameters(expected, index, "climate.set_temperature")["temperature_c"] = 25
|
||||
assert revised["schedule"] == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"change",
|
||||
(
|
||||
"空调温度调整到25度",
|
||||
"第三阶段空调温度调整到25度",
|
||||
"第二阶段持续16分钟",
|
||||
"循环21次",
|
||||
"重复1.5次",
|
||||
"持续30秒",
|
||||
"第二阶段主驾靠背调整到90%",
|
||||
"第二阶段空调温度调整到99度",
|
||||
"第二阶段打开后备箱",
|
||||
"第二阶段空调温度调整到26度并打开后备箱",
|
||||
),
|
||||
)
|
||||
def test_ambiguous_unsafe_or_unbounded_edit_leaves_original_draft_unchanged(service, change):
|
||||
built = _build(service)
|
||||
original = deepcopy(built["schedule"])
|
||||
revised = _edit(service, built, change)
|
||||
assert revised["status"] in {"needs_confirmation", "validation_failed"}
|
||||
assert not revised.get("mode_profile_draft")
|
||||
assert revised["hardware_execution_allowed"] is False
|
||||
source = service._load_mode_profile_draft_reference(
|
||||
{
|
||||
key: built["mode_profile_draft"][key]
|
||||
for key in ("name", "source_request_id", "vehicle_id")
|
||||
},
|
||||
conversation_id="conv-schedule-edit",
|
||||
mode_name="上车预热模式",
|
||||
vehicle_id="denza_n7",
|
||||
)[1]
|
||||
assert source["schedule"] == original
|
||||
|
||||
|
||||
def test_saved_schedule_edit_revalidates_and_persists_timeline_not_flat_actions(service):
|
||||
built = _build(service)
|
||||
draft = built["mode_profile_draft"]
|
||||
saved = service.create_mode_profile_card(
|
||||
draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"]
|
||||
)
|
||||
mode_id = saved["mode_profile"]["id"]
|
||||
profile = service.mode_profiles.get_profile(mode_id, "denza_n7")
|
||||
path = Path(profile.source)
|
||||
before = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
rejected = service.preview_native_can(
|
||||
"把上车预热模式的空调温度调整到26度",
|
||||
provider="local",
|
||||
conversation_id="conv-saved-schedule-edit",
|
||||
execution_mode="hardware",
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
assert rejected["status"] == "needs_confirmation"
|
||||
assert hashlib.sha256(path.read_bytes()).hexdigest() == before
|
||||
|
||||
revised = service.preview_native_can(
|
||||
"把上车预热模式的第二阶段空调温度调整到26度",
|
||||
provider="local",
|
||||
conversation_id="conv-saved-schedule-edit",
|
||||
execution_mode="hardware",
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
assert revised["status"] == "succeeded", revised.get("validation")
|
||||
assert revised["orchestration_route"] == "mode_profile_update"
|
||||
assert revised["hardware_execution_allowed"] is False
|
||||
actual = service.mode_profiles.get_profile(mode_id, "denza_n7").schedule.to_dict()
|
||||
expected = deepcopy(built["schedule"])
|
||||
_parameters(expected, 1, "climate.set_temperature")["temperature_c"] = 26
|
||||
assert actual == expected
|
||||
assert revised["schedule"] == expected
|
||||
assert revised["mode_profile"]["behavior"] == "cyclic"
|
||||
|
||||
|
||||
def test_combined_phase_action_hold_then_repeat_revision_preserves_timeline(service):
|
||||
built = _build(service)
|
||||
revised = _edit(service, built, "第二阶段空调温度调整到26度,持续3分钟")
|
||||
assert revised["status"] == "succeeded", revised.get("mode_creation_failure")
|
||||
repeated = _edit(service, revised, "重复两次")
|
||||
assert repeated["status"] == "succeeded", repeated.get("mode_creation_failure")
|
||||
expected = deepcopy(built["schedule"])
|
||||
_parameters(expected, 1, "climate.set_temperature")["temperature_c"] = 26
|
||||
expected["phases"][1]["duration_ms"] = 180000
|
||||
expected["repeat_count"] = 2
|
||||
assert repeated["schedule"] == expected
|
||||
assert repeated["mode_profile_draft"]["draft_revision"] == 3
|
||||
draft = repeated["mode_profile_draft"]
|
||||
saved = service.create_mode_profile_card(
|
||||
draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"]
|
||||
)
|
||||
assert (
|
||||
service.mode_profiles.get_profile(
|
||||
saved["mode_profile"]["id"], "denza_n7"
|
||||
).schedule.to_dict()
|
||||
== expected
|
||||
)
|
||||
|
||||
|
||||
def test_all_phases_is_not_mistaken_for_all_vehicle_zones(service):
|
||||
source = service.scene_templates.get_template("boarding-warmup").plan()["schedule"]
|
||||
for phase in source["phases"]:
|
||||
for operation in phase["operations"]:
|
||||
if operation["capability"] == "climate.set_temperature":
|
||||
operation["target_scope"] = "passenger"
|
||||
schedule = ModeSchedule.from_dict(source)
|
||||
updated = edit_schedule(
|
||||
schedule,
|
||||
"全部阶段空调温度调整到25度",
|
||||
parse_operations=lambda _: [
|
||||
{
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_temperature",
|
||||
"target_scope": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": {"temperature_c": 25},
|
||||
}
|
||||
],
|
||||
target_is_explicit=service._mode_update_target_is_explicit,
|
||||
)
|
||||
for index in (0, 1):
|
||||
assert _parameters(updated.to_dict(), index, "climate.set_temperature", "passenger") == {
|
||||
"temperature_c": 25
|
||||
}
|
||||
assert not any(
|
||||
op["capability"] == "climate.set_temperature" and op["target_scope"] == "vehicle"
|
||||
for op in updated.phases[index].operations
|
||||
)
|
||||
|
||||
|
||||
def test_phase_motor_duration_cannot_become_a_hold_duration(service):
|
||||
built = _build(service)
|
||||
result = _edit(service, built, "第二阶段副驾靠背向后移动3s")
|
||||
assert result["status"] in {"needs_confirmation", "validation_failed"}
|
||||
assert not result.get("mode_profile_draft")
|
||||
|
||||
|
||||
def test_stage_selector_does_not_authorize_delayed_action_semantics(service):
|
||||
built = _build(service)
|
||||
result = _edit(service, built, "第二阶段30秒后空调温度调整到26度")
|
||||
assert result["status"] == "needs_confirmation"
|
||||
assert not result.get("mode_profile_draft")
|
||||
@@ -273,7 +273,8 @@ def test_name_only_nap_request_reuses_the_verified_repository_profile(
|
||||
draft = result["mode_profile_draft"]
|
||||
assert isinstance(draft, dict)
|
||||
assert draft["name"] == "小憩模式"
|
||||
assert draft["operation_count"] == len(preset.operations) == 9
|
||||
assert draft["operation_count"] == 9
|
||||
assert len(preset.operations) == 10 # The registry retains both optional seat variants.
|
||||
assert set(draft["domains"]) == {"seat", "climate", "roof", "ambient_light"}
|
||||
assert draft["existing_mode_id"] == "nap"
|
||||
assert isinstance(draft["existing_mode_revision"], str)
|
||||
@@ -283,6 +284,7 @@ def test_name_only_nap_request_reuses_the_verified_repository_profile(
|
||||
actions = _intent_actions(result)
|
||||
assert {action["capability"] for action in actions} == {
|
||||
operation.capability for operation in preset.operations
|
||||
if operation.domain != "seat" or operation.target_scope in {"passenger", "front_right"}
|
||||
} == EXPECTED_NAP_CAPABILITIES
|
||||
expected_targets_and_parameters = {
|
||||
operation.capability: (
|
||||
@@ -292,6 +294,7 @@ def test_name_only_nap_request_reuses_the_verified_repository_profile(
|
||||
operation.parameters,
|
||||
)
|
||||
for operation in preset.operations
|
||||
if operation.domain != "seat" or operation.target_scope in {"passenger", "front_right"}
|
||||
}
|
||||
actual_targets_and_parameters = {
|
||||
action["capability"]: (
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
"""Seat-aware built-in defaults must remain safe and preserve saved user intent."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
|
||||
COMFORT_TEMPLATE_IDS = (
|
||||
"reading",
|
||||
"cinema",
|
||||
"camping",
|
||||
"quiet-night",
|
||||
"reception",
|
||||
"romantic",
|
||||
"winter-warmth",
|
||||
"summer-cooling",
|
||||
"long-trip-comfort",
|
||||
"relaxation",
|
||||
"seat-comfort",
|
||||
)
|
||||
DRIVER_COMFORT_CAPABILITIES = {
|
||||
"seat.set_driver_ventilation_native",
|
||||
"seat.set_driver_heating_native",
|
||||
"seat.set_driver_massage_native",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(tmp_path: Path) -> Iterator[SeatDemoService]:
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false",
|
||||
},
|
||||
):
|
||||
instance = SeatDemoService(REPOSITORY_ROOT)
|
||||
instance.artifact_root = tmp_path / "runs"
|
||||
instance.mode_profiles = ModeProfileRegistry(
|
||||
REPOSITORY_ROOT / "config" / "modes",
|
||||
user_root=tmp_path / "user-modes",
|
||||
)
|
||||
try:
|
||||
with patch.object(
|
||||
instance.hardware_execution,
|
||||
"reserve",
|
||||
side_effect=AssertionError("creating or previewing a mode reached real hardware"),
|
||||
):
|
||||
yield instance
|
||||
finally:
|
||||
instance.close()
|
||||
|
||||
|
||||
def _preview(service: SeatDemoService, instruction: str, *, plan=None) -> dict:
|
||||
result = service.preview_native_can(
|
||||
instruction,
|
||||
provider="local",
|
||||
conversation_id="conv-registered-seat-coverage",
|
||||
execution_mode="hardware",
|
||||
vehicle_id="denza_n7",
|
||||
assistant_plan=plan,
|
||||
)
|
||||
assert result["status"] == "succeeded", result.get("validation")
|
||||
assert result["execution_mode"] == "dry_run"
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
return result
|
||||
|
||||
|
||||
def _action_map(result: dict) -> dict:
|
||||
return {
|
||||
(
|
||||
action["capability"],
|
||||
action["target"].get("instance") or action["target"].get("zone"),
|
||||
): action["parameters"]
|
||||
for action in result["intent_ir"]["spec"]["actions"]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template_id", COMFORT_TEMPLATE_IDS)
|
||||
@pytest.mark.parametrize(("seat_prefix", "targets"), (
|
||||
("", {"passenger"}), ("主驾", {"driver"}),
|
||||
("副驾", {"passenger"}), ("主驾和副驾", {"driver", "passenger"}),
|
||||
))
|
||||
def test_comfort_templates_select_only_requested_seat_without_mutating_seat_seeds(
|
||||
service: SeatDemoService,
|
||||
template_id: str,
|
||||
seat_prefix: str,
|
||||
targets: set[str],
|
||||
) -> None:
|
||||
template = service.scene_templates.get_template(template_id)
|
||||
original_revision = template.revision
|
||||
result = _preview(service, f"构建一个{seat_prefix}{template.name}", plan=template.plan())
|
||||
actions = _action_map(result)
|
||||
seat_keys = {key for key in actions if key[0].startswith("seat.")}
|
||||
assert {target for _, target in seat_keys} == targets
|
||||
assert {
|
||||
operation.target_scope for operation in template.operations if operation.domain == "seat"
|
||||
} == {"driver", "passenger"}
|
||||
assert all(
|
||||
operation.capability in DRIVER_COMFORT_CAPABILITIES
|
||||
for operation in template.operations
|
||||
if operation.domain == "seat" and operation.target_scope == "driver"
|
||||
), "driver positional or timed movement must not enter static comfort defaults"
|
||||
assert not any(
|
||||
"massage" in capability for capability, target in seat_keys if target == "passenger"
|
||||
)
|
||||
assert actions == {
|
||||
(operation.capability, operation.target_scope): operation.parameters
|
||||
for operation in template.operations
|
||||
if operation.domain != "seat" or operation.target_scope in targets
|
||||
}
|
||||
assert result["mode_profile_draft"]["generation_source"] == "scene_template_copy"
|
||||
assert service.scene_templates.get_template(template_id).revision == original_revision
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode_id", "mode_name"),
|
||||
(
|
||||
("office", "办公模式"),
|
||||
("nap", "小憩模式"),
|
||||
("meditation_space", "冥想空间模式"),
|
||||
("leisure_lounger", "休闲躺椅模式"),
|
||||
),
|
||||
)
|
||||
@pytest.mark.parametrize(("seat_prefix", "targets"), (
|
||||
("", {"passenger"}), ("主驾", {"driver"}),
|
||||
("副驾", {"passenger"}), ("主驾和副驾", {"driver", "passenger"}),
|
||||
))
|
||||
def test_registered_cabin_modes_select_only_requested_seat_on_creation(
|
||||
service: SeatDemoService,
|
||||
mode_id: str,
|
||||
mode_name: str,
|
||||
seat_prefix: str,
|
||||
targets: set[str],
|
||||
) -> None:
|
||||
result = _preview(service, f"构建一个{seat_prefix}{mode_name}")
|
||||
actions = _action_map(result)
|
||||
assert {target for capability, target in actions if capability.startswith("seat.")} == targets
|
||||
if "passenger" in targets:
|
||||
assert ("seat.set_backrest_recline", "passenger") in actions
|
||||
draft = result["mode_profile_draft"]
|
||||
if seat_prefix:
|
||||
assert "existing_mode_id" not in draft, "a selected-seat copy must not overwrite the original preset"
|
||||
else:
|
||||
assert draft["existing_mode_id"] == mode_id
|
||||
assert draft["generation_source"] == "registered_mode_profile"
|
||||
profile = service.mode_profiles.get_profile(mode_id, "denza_n7")
|
||||
assert actions == {
|
||||
(operation.capability, operation.target_scope): operation.parameters
|
||||
for operation in profile.operations
|
||||
if operation.domain != "seat" or operation.target_scope in targets
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template_id", ("fresh-air", "rain-defrost"))
|
||||
def test_air_quality_and_visibility_templates_do_not_invent_seat_actions(
|
||||
service: SeatDemoService,
|
||||
template_id: str,
|
||||
) -> None:
|
||||
template = service.scene_templates.get_template(template_id)
|
||||
result = _preview(service, f"构建一个{template.name}", plan=template.plan())
|
||||
assert not any(capability.startswith("seat.") for capability, _ in _action_map(result))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"template_id",
|
||||
(
|
||||
"long-trip-comfort",
|
||||
"summer-cooling",
|
||||
"winter-warmth",
|
||||
"reception",
|
||||
"quiet-night",
|
||||
"romantic",
|
||||
),
|
||||
)
|
||||
def test_non_posture_templates_keep_both_seat_positions_unchanged(
|
||||
service: SeatDemoService,
|
||||
template_id: str,
|
||||
) -> None:
|
||||
template = service.scene_templates.get_template(template_id)
|
||||
assert all(
|
||||
"position_pct" not in operation.parameters and "duration_ms" not in operation.parameters
|
||||
for operation in template.operations
|
||||
if operation.domain == "seat"
|
||||
)
|
||||
|
||||
|
||||
def test_native_driver_and_passenger_thermal_levels_are_not_confused(
|
||||
service: SeatDemoService,
|
||||
) -> None:
|
||||
for template_id in (
|
||||
"reading",
|
||||
"camping",
|
||||
"quiet-night",
|
||||
"reception",
|
||||
"romantic",
|
||||
"long-trip-comfort",
|
||||
"seat-comfort",
|
||||
):
|
||||
template = service.scene_templates.get_template(template_id)
|
||||
params = {(op.capability, op.target_scope): op.parameters for op in template.operations}
|
||||
assert params[("seat.set_driver_ventilation_native", "driver")] == {
|
||||
"state": "on",
|
||||
"level": 2,
|
||||
}
|
||||
assert params[("seat.set_ventilation", "passenger")] == {"state": "on", "level": 1}
|
||||
|
||||
|
||||
def test_upgraded_template_does_not_silently_add_seats_to_a_saved_custom_mode(
|
||||
service: SeatDemoService,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
# Simulate a previously saved user preference with the same name as a template.
|
||||
original = _preview(service, "构建一个观影模式,空调温度调整到25度")
|
||||
draft = original["mode_profile_draft"]
|
||||
assert len(_action_map(original)) == 1
|
||||
saved = service.create_mode_profile_card(
|
||||
draft["name"],
|
||||
draft["instruction"],
|
||||
draft["source_request_id"],
|
||||
draft["vehicle_id"],
|
||||
)
|
||||
assert saved["status"] == "succeeded"
|
||||
paths = list((tmp_path / "user-modes").rglob("*.yaml"))
|
||||
hashes = {path: hashlib.sha256(path.read_bytes()).hexdigest() for path in paths}
|
||||
|
||||
repeated = _preview(service, "构建一个观影模式")
|
||||
assert repeated["mode_profile_draft"]["generation_source"] == "registered_mode_profile"
|
||||
assert _action_map(repeated) == _action_map(original)
|
||||
assert {path: hashlib.sha256(path.read_bytes()).hexdigest() for path in paths} == hashes
|
||||
assert len(service.scene_templates.get_template("cinema").operations) == 11
|
||||
|
||||
|
||||
def test_explicit_seat_only_requirement_is_not_expanded_to_other_seat_defaults(
|
||||
service: SeatDemoService,
|
||||
) -> None:
|
||||
result = _preview(service, "构建一个阅读模式,副驾座椅通风关闭")
|
||||
assert _action_map(result) == {("seat.set_ventilation", "passenger"): {"state": "off"}}
|
||||
assert result["mode_profile_draft"]["generation_source"] == "explicit_scene_requirements"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode_id", ("office", "nap", "meditation_space", "leisure_lounger"))
|
||||
@pytest.mark.parametrize(("seat", "spoken_seat"), (("driver", "主驾"), ("passenger", "副驾驶")))
|
||||
def test_explicit_single_seat_invocation_selects_without_mutating_cabin_mode(
|
||||
service: SeatDemoService,
|
||||
mode_id: str,
|
||||
seat: str,
|
||||
spoken_seat: str,
|
||||
) -> None:
|
||||
profile = service.mode_profiles.get_profile(mode_id, "denza_n7")
|
||||
original_revision = service.mode_profiles.profile_revision(profile)
|
||||
expected = {
|
||||
(operation.capability, operation.target_scope): operation.parameters
|
||||
for operation in profile.operations
|
||||
if operation.domain != "seat" or operation.target_scope == seat
|
||||
}
|
||||
result = service.preview_native_can(
|
||||
f"请让{spoken_seat}进入{profile.name}",
|
||||
provider="local",
|
||||
conversation_id="conv-explicit-seat-mode-selection",
|
||||
execution_mode="dry_run",
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
assert result["status"] == "succeeded", result.get("validation")
|
||||
assert _action_map(result) == expected
|
||||
assert (
|
||||
service.mode_profiles.profile_revision(
|
||||
service.mode_profiles.get_profile(mode_id, "denza_n7"),
|
||||
)
|
||||
== original_revision
|
||||
)
|
||||
assert any(
|
||||
operation.target_scope != seat
|
||||
for operation in service.mode_profiles.get_profile(mode_id, "denza_n7").operations
|
||||
if operation.domain == "seat"
|
||||
), "single-seat execution must not edit the persisted dual-seat profile"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode_id", "spoken_seat"),
|
||||
[
|
||||
(mode_id, seat)
|
||||
for mode_id in ("zero_gravity", "easy_entry_exit", "rocking_horse", "office")
|
||||
for seat in ("主驾", "后排", "所有座椅")
|
||||
if (mode_id, seat) != ("office", "主驾")
|
||||
],
|
||||
)
|
||||
def test_mode_invocation_still_rejects_genuinely_unsupported_seat_scopes(
|
||||
service: SeatDemoService,
|
||||
mode_id: str,
|
||||
spoken_seat: str,
|
||||
) -> None:
|
||||
profile = service.mode_profiles.get_profile(mode_id, "denza_n7")
|
||||
result = service.preview_native_can(
|
||||
f"请让{spoken_seat}进入{profile.name}",
|
||||
provider="local",
|
||||
conversation_id="conv-explicit-seat-mode-rejection",
|
||||
execution_mode="dry_run",
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
assert result["status"] in {"no_action", "needs_confirmation"}
|
||||
assert _action_map(result) == {}
|
||||
assert any(
|
||||
issue.get("reason") == "mode_target_scope_mismatch"
|
||||
for issue in result["intent_ir"]["spec"].get("ambiguities", [])
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
("创建一个主驾办公模式", "修改办公模式,只要主驾", "主驾办公模式"),
|
||||
)
|
||||
def test_mode_seat_selection_requires_an_explicit_invocation_not_configuration(
|
||||
service: SeatDemoService,
|
||||
message: str,
|
||||
) -> None:
|
||||
profile = service.mode_profiles.get_profile("office", "denza_n7")
|
||||
assert service._explicit_mode_seat_selection(profile, message) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode_id", ("office", "nap", "meditation_space", "leisure_lounger"))
|
||||
def test_unscoped_mode_invocation_keeps_registered_native_driver_actions(
|
||||
service: SeatDemoService,
|
||||
mode_id: str,
|
||||
) -> None:
|
||||
profile = service.mode_profiles.get_profile(mode_id, "denza_n7")
|
||||
result = service.preview_native_can(
|
||||
f"进入{profile.name}",
|
||||
provider="local",
|
||||
conversation_id="conv-mode-native-seat-preserved",
|
||||
execution_mode="dry_run",
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
assert result["status"] == "succeeded"
|
||||
assert _action_map(result) == {
|
||||
(operation.capability, operation.target_scope): operation.parameters
|
||||
for operation in profile.operations
|
||||
}
|
||||
@@ -87,7 +87,10 @@ def test_explicit_scene_seat_scope_rejects_cross_seat_plan_atomically(scene_serv
|
||||
assert result.get("command_plans") == {}
|
||||
intent = result.get("intent_ir") or {}
|
||||
assert (intent.get("spec") or {}).get("actions", []) == []
|
||||
assert any("座位范围" in item["message"] for item in result["validation"])
|
||||
assert any(
|
||||
"座位范围" in item["message"] or item.get("code") == "MODE_SEAT_SCOPE_UNAVAILABLE"
|
||||
for item in result["validation"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("message", "scopes"), (
|
||||
@@ -96,9 +99,9 @@ def test_explicit_scene_seat_scope_rejects_cross_seat_plan_atomically(scene_serv
|
||||
("创建一个仅主驾舒适模式", ("driver",)),
|
||||
("构建一个主驾和副驾座椅舒适模式", ("driver", "passenger")),
|
||||
("构建一个主副驾座椅舒适模式", ("driver", "passenger")),
|
||||
("构建一个云端座椅舒适模式", ("driver", "passenger")),
|
||||
("构建一个副驾空调与座椅舒适模式", ("driver", "passenger")),
|
||||
("构建一个座椅舒适与副驾阅读灯模式", ("driver", "passenger")),
|
||||
("构建一个云端座椅舒适模式", ("passenger",)),
|
||||
("构建一个副驾空调与座椅舒适模式", ("passenger",)),
|
||||
("构建一个座椅舒适与副驾阅读灯模式", ("passenger",)),
|
||||
("构建一个主驾座椅与副驾空调舒适模式", ("driver",)),
|
||||
("构建一个主驾阅读灯与副驾座椅舒适模式", ("passenger",)),
|
||||
))
|
||||
@@ -120,7 +123,6 @@ def test_scene_scope_preserves_supported_targets_without_borrowing_other_device_
|
||||
@pytest.mark.parametrize("message", (
|
||||
"构建一个主驾舒适模式,副驾不要动",
|
||||
"构建一个主驾舒适模式,不要动副驾",
|
||||
"构建一个舒展模式,座椅靠背调整到45%",
|
||||
))
|
||||
def test_explicit_atomic_requirements_do_not_receive_scene_scope_relaxation(
|
||||
scene_service, message,
|
||||
@@ -131,6 +133,19 @@ def test_explicit_atomic_requirements_do_not_receive_scene_scope_relaxation(
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
|
||||
|
||||
def test_unqualified_explicit_seat_requirement_defaults_to_passenger_without_model_additions(scene_service):
|
||||
result = _preview(scene_service, "构建一个舒展模式,座椅靠背调整到45%", execution_mode="dry_run")
|
||||
assert result["status"] == "succeeded", result.get("validation")
|
||||
assert result["execution_mode"] == "dry_run"
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
assert result["mode_profile_draft"]["operation_count"] == 1
|
||||
actions = result["intent_ir"]["spec"]["actions"]
|
||||
assert len(actions) == 1
|
||||
assert actions[0]["capability"] == "seat.set_backrest_recline"
|
||||
assert actions[0]["target"]["instance"] == "passenger"
|
||||
assert actions[0]["parameters"] == {"position_pct": 45}
|
||||
|
||||
|
||||
def test_explicit_atomic_requirements_override_label_without_adding_model_actions(scene_service):
|
||||
result = _preview(scene_service, "构建一个主驾舒适模式,副驾靠背调整到45%")
|
||||
assert result["status"] == "succeeded"
|
||||
|
||||
@@ -144,7 +144,7 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run(
|
||||
templates = snapshot["scene_templates"]
|
||||
|
||||
assert isinstance(templates, list)
|
||||
assert len(templates) == 13
|
||||
assert len(templates) == 15
|
||||
assert all(template["assistant_callable"] is True for template in templates)
|
||||
|
||||
with patch.object(
|
||||
@@ -169,11 +169,30 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run(
|
||||
assert result["orchestration_route"] == "mode_profile_create_preview"
|
||||
draft = result["mode_profile_draft"]
|
||||
assert draft["generation_source"] == "scene_template_copy"
|
||||
assert draft["operation_count"] == template["operation_count"]
|
||||
assert draft["source_template_id"] == template["id"]
|
||||
assert draft["source_template_revision"] == template["template_revision"]
|
||||
assert "existing_mode_id" not in draft
|
||||
|
||||
expected_requests = plan["requests"]
|
||||
if plan.get("schedule"):
|
||||
expected_schedule = deepcopy(plan["schedule"])
|
||||
for phase in expected_schedule["phases"]:
|
||||
phase["operations"] = [
|
||||
op for op in phase["operations"]
|
||||
if op["domain"] != "seat" or op["target_scope"] == "passenger"
|
||||
]
|
||||
assert draft["schedule"] == expected_schedule
|
||||
expected_requests = [
|
||||
{**op, "parameters": [{"name": key, "value": value}
|
||||
for key, value in op["parameters"].items()]}
|
||||
for phase in expected_schedule["phases"] for op in phase["operations"]
|
||||
]
|
||||
else:
|
||||
expected_requests = [
|
||||
request for request in expected_requests
|
||||
if request["domain"] != "seat" or request["target_scope"] == "passenger"
|
||||
]
|
||||
assert draft["operation_count"] == len(expected_requests)
|
||||
expected = {
|
||||
(
|
||||
str(request["capability"]),
|
||||
@@ -187,7 +206,7 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run(
|
||||
sort_keys=True,
|
||||
),
|
||||
)
|
||||
for request in plan["requests"]
|
||||
for request in expected_requests
|
||||
}
|
||||
actual = {
|
||||
(
|
||||
@@ -311,7 +330,10 @@ def test_purpose_descriptions_use_a_concise_name_and_zero_model_template_route(
|
||||
assert draft["name"] == expected_name
|
||||
assert draft["generation_source"] == "scene_template_copy"
|
||||
assert draft["source_template_id"] == template_id
|
||||
assert draft["operation_count"] == len(template.operations)
|
||||
assert draft["operation_count"] == sum(
|
||||
operation.domain != "seat" or operation.target_scope == "passenger"
|
||||
for operation in template.operations
|
||||
)
|
||||
assert passed_plans[0]["metadata"]["template_id"] == template_id
|
||||
assert events[0]["engine"] == "n7web-scene-template"
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
@@ -353,7 +375,7 @@ def test_explicit_settings_on_a_purpose_scene_bypass_template_defaults(
|
||||
("构建一个长途舒适模式", "长途舒适模式", "long-trip-comfort"),
|
||||
),
|
||||
)
|
||||
def test_seating_comfort_requests_preview_both_seats_with_no_model_or_hardware_call(
|
||||
def test_seating_comfort_requests_preview_only_passenger_with_no_model_or_hardware_call(
|
||||
hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path],
|
||||
message: str,
|
||||
expected_name: str,
|
||||
@@ -380,16 +402,13 @@ def test_seating_comfort_requests_preview_both_seats_with_no_model_or_hardware_c
|
||||
for (capability, target), parameters in _action_map(result).items()
|
||||
if capability.startswith("seat.")
|
||||
}
|
||||
assert {target for _capability, target in seat_actions} == {"driver", "passenger"}
|
||||
assert {target for _capability, target in seat_actions} == {"passenger"}
|
||||
assert all(
|
||||
"driver_axis" not in capability and "driver_position" not in capability
|
||||
for capability, _target in seat_actions
|
||||
)
|
||||
assert ("seat.set_driver_massage_native", "passenger") not in seat_actions
|
||||
if template_id in {"seat-comfort", "long-trip-comfort"}:
|
||||
assert seat_actions["seat.set_driver_ventilation_native", "driver"] == {
|
||||
"state": "on", "level": 2,
|
||||
}
|
||||
assert seat_actions["seat.set_ventilation", "passenger"] == {
|
||||
"state": "on", "level": 1,
|
||||
}
|
||||
@@ -422,7 +441,7 @@ def test_unregistered_seat_scene_keeps_both_explicit_targets_through_native_merg
|
||||
side_effect=AssertionError("scene composition reached hardware execution"),
|
||||
):
|
||||
result = service.preview_native_can(
|
||||
f"构建一个{name}", provider="local", conversation_id="conv-seat-generated",
|
||||
f"构建一个主驾和副驾的{name}", provider="local", conversation_id="conv-seat-generated",
|
||||
execution_mode="hardware", vehicle_id="denza_n7", assistant_plan=plan,
|
||||
)
|
||||
assert result["status"] == "succeeded"
|
||||
@@ -437,7 +456,7 @@ def test_unregistered_seat_scene_keeps_both_explicit_targets_through_native_merg
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
|
||||
|
||||
def test_explicit_unscoped_seat_instruction_keeps_target_ambiguity_guard(
|
||||
def test_unscoped_seat_instruction_inside_mode_creation_defaults_to_passenger(
|
||||
hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path],
|
||||
) -> None:
|
||||
service, _catalog, _user_root = hybrid_service
|
||||
@@ -446,8 +465,11 @@ def test_explicit_unscoped_seat_instruction_keeps_target_ambiguity_guard(
|
||||
conversation_id="conv-seat-explicit-target", execution_mode="hardware",
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
assert result["status"] != "succeeded"
|
||||
assert result.get("mode_profile_draft") is None
|
||||
assert result["status"] == "succeeded"
|
||||
assert _action_map(result) == {
|
||||
("seat.set_backrest_recline", "passenger"): {"position_pct": 45},
|
||||
}
|
||||
assert result.get("mode_profile_draft")
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
|
||||
|
||||
@@ -552,7 +574,7 @@ def test_template_copy_can_be_revised_twice_without_losing_sibling_actions(
|
||||
assert shade_revised["execution_mode"] == final["execution_mode"] == "dry_run"
|
||||
assert shade_revised["hardware_execution_allowed"] is False
|
||||
assert final["hardware_execution_allowed"] is False
|
||||
assert len(built_actions) == len(shade_actions) == len(final_actions) == 7
|
||||
assert len(built_actions) == len(shade_actions) == len(final_actions) == 10
|
||||
assert built_actions[shade_key] == {"position_pct": 0}
|
||||
assert shade_actions[shade_key] == final_actions[shade_key] == {"position_pct": 100}
|
||||
assert built_actions[temperature_key] == shade_actions[temperature_key] == {"temperature_c": 24}
|
||||
|
||||
Reference in New Issue
Block a user