472 lines
21 KiB
Python
472 lines
21 KiB
Python
"""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()
|