feat: add seat-scoped static and dynamic assistant modes
This commit is contained in:
@@ -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")
|
||||
Reference in New Issue
Block a user