629 lines
21 KiB
Python
629 lines
21 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
from collections.abc import Mapping
|
|
|
|
import pytest
|
|
from jsonschema import Draft202012Validator
|
|
|
|
from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog
|
|
from cognitive_agent.application.scene_composer import (
|
|
build_scene_output_schema,
|
|
normalize_scene_output,
|
|
scene_has_explicit_control_object,
|
|
scene_required_native_fallbacks,
|
|
scene_required_peripheral_refs,
|
|
scene_semantic_guidance,
|
|
)
|
|
from cognitive_agent.application.seat_service import SeatDemoService
|
|
|
|
|
|
def _context() -> dict[str, object]:
|
|
return {
|
|
"schema_version": "n7web-scene-capabilities/v1",
|
|
"vehicle": {"id": "denza_n7", "name": "腾势 N7"},
|
|
"capabilities": [
|
|
{
|
|
"id": "climate.set_temperature",
|
|
"domain": "climate",
|
|
"target_scopes": ["driver", "vehicle"],
|
|
"parameters": [
|
|
{
|
|
"name": "temperature_c",
|
|
"type": "number",
|
|
"minimum": 18,
|
|
"maximum": 32,
|
|
"required": True,
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"id": "roof.set_shade_position",
|
|
"domain": "roof",
|
|
"target_scopes": ["vehicle"],
|
|
"parameters": [
|
|
{
|
|
"name": "position_pct",
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"maximum": 100,
|
|
"required": True,
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"id": "ambient_light.set_mode",
|
|
"domain": "ambient_light",
|
|
"target_scopes": ["vehicle"],
|
|
"parameters": [
|
|
{
|
|
"name": "mode",
|
|
"type": "string",
|
|
"allowed_values": ["cinema", "rest", "off"],
|
|
"required": True,
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"id": "seat.set_longitudinal_position",
|
|
"domain": "seat",
|
|
"target_scopes": ["passenger"],
|
|
"parameters": [
|
|
{
|
|
"name": "position_pct",
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"maximum": 100,
|
|
"required": False,
|
|
},
|
|
{
|
|
"name": "distance_mm",
|
|
"type": "number",
|
|
"minimum": 0,
|
|
"maximum": 215,
|
|
"required": False,
|
|
"description": "Relative movement from the current position.",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"id": "seat.set_backrest_recline",
|
|
"domain": "seat",
|
|
"target_scopes": ["passenger"],
|
|
"parameters": [
|
|
{
|
|
"name": "angle_deg",
|
|
"type": "number",
|
|
"minimum": 95,
|
|
"maximum": 163,
|
|
"required": False,
|
|
},
|
|
{
|
|
"name": "angle_delta_deg",
|
|
"type": "number",
|
|
"minimum": 0,
|
|
"maximum": 68,
|
|
"required": False,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
def _output(request: Mapping[str, object]) -> dict[str, object]:
|
|
return {
|
|
"requests": [copy.deepcopy(dict(request))],
|
|
"ambiguities": [],
|
|
"confidence": 0.9,
|
|
}
|
|
|
|
|
|
def _request(
|
|
capability: str,
|
|
target_scope: str,
|
|
name: str,
|
|
value: object,
|
|
) -> dict[str, object]:
|
|
return {
|
|
"capability": capability,
|
|
"target_scope": target_scope,
|
|
"parameters": {name: value},
|
|
}
|
|
|
|
|
|
def _branch(schema: Mapping[str, object], capability: str) -> Mapping[str, object]:
|
|
request_schema = schema["properties"]["requests"]
|
|
branches = request_schema["items"]["anyOf"]
|
|
return next(
|
|
item
|
|
for item in branches
|
|
if item["properties"]["capability"]["enum"] == [capability]
|
|
)
|
|
|
|
|
|
def test_schema_builds_one_capability_specific_request_branch() -> None:
|
|
schema = build_scene_output_schema(_context())
|
|
Draft202012Validator.check_schema(schema)
|
|
|
|
request_items = schema["properties"]["requests"]["items"]
|
|
branches = request_items["anyOf"]
|
|
assert len(branches) == 5
|
|
|
|
temperature = _branch(schema, "climate.set_temperature")
|
|
assert temperature["properties"]["target_scope"]["enum"] == ["driver", "vehicle"]
|
|
value_schema = temperature["properties"]["parameters"]["anyOf"][0]["properties"][
|
|
"temperature_c"
|
|
]
|
|
assert value_schema == {"type": "number", "minimum": 18, "maximum": 32}
|
|
|
|
ambient_mode = _branch(schema, "ambient_light.set_mode")
|
|
mode_value = ambient_mode["properties"]["parameters"]["anyOf"][0]["properties"][
|
|
"mode"
|
|
]
|
|
assert mode_value == {"type": "string", "enum": ["cinema", "rest", "off"]}
|
|
|
|
|
|
def test_schema_avoids_unsupported_structured_output_composition_keywords() -> None:
|
|
schema = build_scene_output_schema(_context())
|
|
|
|
def keys(value: object) -> set[str]:
|
|
if isinstance(value, Mapping):
|
|
return set(value) | {
|
|
nested_key
|
|
for nested in value.values()
|
|
for nested_key in keys(nested)
|
|
}
|
|
if isinstance(value, list):
|
|
return {nested_key for nested in value for nested_key in keys(nested)}
|
|
return set()
|
|
|
|
assert not keys(schema) & {
|
|
"allOf",
|
|
"contains",
|
|
"dependentRequired",
|
|
"dependentSchemas",
|
|
"else",
|
|
"if",
|
|
"maxContains",
|
|
"minContains",
|
|
"not",
|
|
"then",
|
|
}
|
|
assert "anyOf" in keys(schema)
|
|
|
|
|
|
def test_relative_scene_parameters_are_not_exposed_by_the_schema() -> None:
|
|
schema_text = json.dumps(build_scene_output_schema(_context()), ensure_ascii=False)
|
|
|
|
assert "distance_mm" not in schema_text
|
|
assert "angle_delta_deg" not in schema_text
|
|
assert "position_pct" in schema_text
|
|
assert "angle_deg" in schema_text
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"scene_request",
|
|
(
|
|
_request("climate.set_temperature", "vehicle", "temperature_c", 24.5),
|
|
_request("roof.set_shade_position", "vehicle", "position_pct", 100),
|
|
_request("ambient_light.set_mode", "vehicle", "mode", "cinema"),
|
|
_request("seat.set_longitudinal_position", "passenger", "position_pct", 80),
|
|
_request("seat.set_backrest_recline", "passenger", "angle_deg", 125),
|
|
),
|
|
)
|
|
def test_schema_accepts_only_valid_capability_bound_requests(
|
|
scene_request: Mapping[str, object],
|
|
) -> None:
|
|
schema = build_scene_output_schema(_context())
|
|
|
|
assert Draft202012Validator(schema).is_valid(_output(scene_request))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"scene_request",
|
|
(
|
|
# Target and parameter names valid elsewhere in the catalog must not be
|
|
# freely combined with a different capability.
|
|
_request("roof.set_shade_position", "driver", "position_pct", 100),
|
|
_request("climate.set_temperature", "vehicle", "position_pct", 24),
|
|
_request("seat.set_longitudinal_position", "passenger", "distance_mm", 100),
|
|
_request("seat.set_backrest_recline", "passenger", "angle_delta_deg", 20),
|
|
# Scalar type, enum and numeric bounds come from that exact parameter.
|
|
_request("climate.set_temperature", "vehicle", "temperature_c", "24"),
|
|
_request("climate.set_temperature", "vehicle", "temperature_c", 17.9),
|
|
_request("roof.set_shade_position", "vehicle", "position_pct", 101),
|
|
_request("ambient_light.set_mode", "vehicle", "mode", "party"),
|
|
),
|
|
)
|
|
def test_schema_rejects_cross_capability_relative_or_out_of_contract_values(
|
|
scene_request: Mapping[str, object],
|
|
) -> None:
|
|
schema = build_scene_output_schema(_context())
|
|
|
|
assert not Draft202012Validator(schema).is_valid(_output(scene_request))
|
|
|
|
|
|
def test_schema_requires_exact_required_parameters_and_rejects_mutually_exclusive_forms() -> None:
|
|
schema = build_scene_output_schema(_context())
|
|
validator = Draft202012Validator(schema)
|
|
missing = {
|
|
"capability": "climate.set_temperature",
|
|
"target_scope": "vehicle",
|
|
"parameters": {},
|
|
}
|
|
conflicting = {
|
|
"capability": "seat.set_backrest_recline",
|
|
"target_scope": "passenger",
|
|
"parameters": {"position_pct": 50, "angle_deg": 125},
|
|
}
|
|
|
|
assert not validator.is_valid(_output(missing))
|
|
assert not validator.is_valid(_output(conflicting))
|
|
|
|
|
|
def test_schema_optional_parameter_never_substitutes_for_a_required_parameter() -> None:
|
|
context = _context()
|
|
context["capabilities"].append(
|
|
{
|
|
"id": "seat.set_heating",
|
|
"domain": "seat",
|
|
"target_scopes": ["driver"],
|
|
"parameters": [
|
|
{
|
|
"name": "state",
|
|
"type": "string",
|
|
"allowed_values": ["on", "off"],
|
|
"required": True,
|
|
},
|
|
{
|
|
"name": "level",
|
|
"type": "integer",
|
|
"minimum": 1,
|
|
"maximum": 3,
|
|
"required": False,
|
|
},
|
|
],
|
|
}
|
|
)
|
|
validator = Draft202012Validator(build_scene_output_schema(context))
|
|
|
|
assert not validator.is_valid(
|
|
_output(
|
|
{
|
|
"capability": "seat.set_heating",
|
|
"target_scope": "driver",
|
|
"parameters": {"level": 2},
|
|
}
|
|
)
|
|
)
|
|
assert validator.is_valid(
|
|
_output(
|
|
{
|
|
"capability": "seat.set_heating",
|
|
"target_scope": "driver",
|
|
"parameters": {"state": "on"},
|
|
}
|
|
)
|
|
)
|
|
assert validator.is_valid(
|
|
_output(
|
|
{
|
|
"capability": "seat.set_heating",
|
|
"target_scope": "driver",
|
|
"parameters": {"state": "on", "level": 2},
|
|
}
|
|
)
|
|
)
|
|
|
|
|
|
def test_normalize_remains_compatible_with_a_schema_valid_response() -> None:
|
|
context = _context()
|
|
raw = _output(_request("climate.set_temperature", "driver", "temperature_c", 24))
|
|
assert Draft202012Validator(build_scene_output_schema(context)).is_valid(raw)
|
|
|
|
normalized = normalize_scene_output(raw, context)
|
|
|
|
assert normalized["detected_domains"] == ["climate"]
|
|
assert normalized["requests"] == [
|
|
{
|
|
"domain": "climate",
|
|
"capability": "climate.set_temperature",
|
|
"target_scope": "driver",
|
|
"operation": "set",
|
|
"duration_ms": None,
|
|
"parameters": [{"name": "temperature_c", "value": 24}],
|
|
"reason": "Scene Composer 候选动作,等待应用安全链复核。",
|
|
}
|
|
]
|
|
|
|
|
|
def test_normalize_rejects_duplicate_parameter_names_even_without_schema_enforcement() -> None:
|
|
raw = _output(_request("climate.set_temperature", "vehicle", "temperature_c", 24))
|
|
raw["requests"][0]["parameters"] = [
|
|
{"name": "temperature_c", "value": 24},
|
|
{"name": "temperature_c", "value": 25},
|
|
]
|
|
|
|
with pytest.raises(ValueError, match="invalid parameter"):
|
|
normalize_scene_output(raw, _context())
|
|
|
|
|
|
def test_production_scene_schema_remains_below_app_server_limit() -> None:
|
|
catalog = AssistantCapabilityCatalog(SeatDemoService())
|
|
schema = build_scene_output_schema(catalog.scene_composition_context())
|
|
encoded = json.dumps(schema, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
|
|
assert len(encoded) < 64 * 1024
|
|
|
|
|
|
def test_schema_and_normalizer_accept_only_opaque_peripheral_refs() -> None:
|
|
context = _context()
|
|
context["peripheral_actions"] = [
|
|
{
|
|
"ref": "ir:0123456789abcdef01234567",
|
|
"device_label": "氛围灯带",
|
|
"action_label": "设置为红色",
|
|
}
|
|
]
|
|
schema = build_scene_output_schema(context)
|
|
raw = {
|
|
"requests": [{"capability_ref": "ir:0123456789abcdef01234567"}],
|
|
"ambiguities": [],
|
|
"confidence": 0.91,
|
|
}
|
|
|
|
assert Draft202012Validator(schema).is_valid(raw)
|
|
assert not Draft202012Validator(schema).is_valid(
|
|
{
|
|
**raw,
|
|
"requests": [
|
|
{
|
|
"capability_ref": "ir:invented",
|
|
"slot": 7,
|
|
"card_uid": "2CC8B103",
|
|
}
|
|
],
|
|
}
|
|
)
|
|
normalized = normalize_scene_output(raw, context)
|
|
assert normalized["requests"] == [
|
|
{
|
|
"domain": "self_extension",
|
|
"capability": "infrared.emit",
|
|
"target_scope": "ir:0123456789abcdef01234567",
|
|
"operation": "emit",
|
|
"duration_ms": None,
|
|
"parameters": [
|
|
{
|
|
"name": "capability_ref",
|
|
"value": "ir:0123456789abcdef01234567",
|
|
}
|
|
],
|
|
"reason": (
|
|
"Scene Composer selected a server-issued learned-device reference; "
|
|
"runtime binding resolution is still required."
|
|
),
|
|
}
|
|
]
|
|
|
|
|
|
def test_schema_remains_bounded_with_full_peripheral_catalog() -> None:
|
|
context = _context()
|
|
context["peripheral_actions"] = [
|
|
{"ref": f"ir:{index:024x}"}
|
|
for index in range(248)
|
|
]
|
|
|
|
schema = build_scene_output_schema(context)
|
|
encoded = json.dumps(schema, ensure_ascii=False, separators=(",", ":")).encode()
|
|
|
|
assert len(encoded) < 64 * 1024
|
|
peripheral_branch = schema["properties"]["requests"]["items"]["anyOf"][-1]
|
|
assert len(
|
|
peripheral_branch["properties"]["capability_ref"]["enum"]
|
|
) == 248
|
|
|
|
|
|
def test_learning_mode_is_grounded_as_free_semantic_composition() -> None:
|
|
context = _context()
|
|
guidance = scene_semantic_guidance("学习模式")
|
|
context["scene_semantics"] = guidance
|
|
schema = build_scene_output_schema(context)
|
|
validator = Draft202012Validator(schema)
|
|
valid = _output(
|
|
_request(
|
|
"climate.set_temperature",
|
|
"vehicle",
|
|
"temperature_c",
|
|
24,
|
|
)
|
|
)
|
|
|
|
assert guidance["grounded"] is True
|
|
assert guidance["family"] == "focused_learning"
|
|
assert scene_semantic_guidance("灯光主题模式")["family"] == (
|
|
"lighting_ambience"
|
|
)
|
|
assert "interior_light.set_reading_power" in guidance[
|
|
"preferred_capability_ids"
|
|
]
|
|
assert schema["properties"]["requests"]["minItems"] == 1
|
|
assert schema["properties"]["ambiguities"]["maxItems"] == 0
|
|
assert validator.is_valid(valid)
|
|
assert not validator.is_valid(
|
|
{"requests": [], "ambiguities": [], "confidence": 0.4}
|
|
)
|
|
assert not validator.is_valid(
|
|
{**valid, "ambiguities": ["不知道学习指什么"]}
|
|
)
|
|
with pytest.raises(ValueError, match="at least one request"):
|
|
normalize_scene_output(
|
|
{"requests": [], "ambiguities": [], "confidence": 0.4},
|
|
context,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("mode_name", ("浪漫模式", "中秋欢快模式"))
|
|
def test_romantic_and_mid_autumn_require_decorative_light_actions(
|
|
mode_name: str,
|
|
) -> None:
|
|
context = _context()
|
|
context["peripheral_actions"] = [
|
|
{
|
|
"ref": "ir:flash-open",
|
|
"device_label": "闪烁灯",
|
|
"object_names": ["闪烁灯", "闪光灯"],
|
|
"action_label": "打开",
|
|
"action_names": ["打开", "开启"],
|
|
},
|
|
{
|
|
"ref": "ir:flash-red",
|
|
"device_label": "闪烁灯",
|
|
"object_names": ["闪烁灯", "闪光灯"],
|
|
"action_label": "切换为红色",
|
|
"action_names": ["切换为红色", "设置为红色"],
|
|
},
|
|
{
|
|
"ref": "ir:flash-close",
|
|
"device_label": "闪烁灯",
|
|
"object_names": ["闪烁灯", "闪光灯"],
|
|
"action_label": "关闭",
|
|
"action_names": ["关闭", "熄灭"],
|
|
},
|
|
{
|
|
"ref": "ir:strip-open",
|
|
"device_label": "灯带",
|
|
"object_names": ["灯带", "LED 灯条"],
|
|
"action_label": "打开",
|
|
"action_names": ["打开", "开启"],
|
|
},
|
|
{
|
|
"ref": "ir:strip-red",
|
|
"device_label": "灯带",
|
|
"object_names": ["灯带", "LED 灯条"],
|
|
"action_label": "切换为红色",
|
|
"action_names": ["切换为红色", "设置为红色"],
|
|
},
|
|
{
|
|
"ref": "ir:ambient-open",
|
|
"device_label": "外设氛围灯",
|
|
"object_names": ["外设氛围灯", "氛围灯"],
|
|
"action_label": "打开",
|
|
"action_names": ["打开", "开启"],
|
|
},
|
|
]
|
|
required = scene_required_peripheral_refs(mode_name, context)
|
|
semantics = scene_semantic_guidance(mode_name)
|
|
semantics["peripheral_policy"] = {"required_refs": required}
|
|
context["scene_semantics"] = semantics
|
|
schema = build_scene_output_schema(context)
|
|
|
|
assert required == [
|
|
"ir:flash-open",
|
|
"ir:strip-open",
|
|
]
|
|
assert scene_required_peripheral_refs(
|
|
mode_name,
|
|
context,
|
|
include_default_bundle=True,
|
|
) == [
|
|
"ir:flash-open",
|
|
"ir:ambient-open",
|
|
"ir:strip-open",
|
|
]
|
|
assert schema["properties"]["requests"]["maxItems"] == 18
|
|
if "中秋" in mode_name:
|
|
assert semantics["family"] == "festival_celebration"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("mode_name", "expected_shade"),
|
|
(("浪漫模式", 0), ("中秋欢快模式", 100)),
|
|
)
|
|
def test_required_native_fallbacks_cover_climate_ambient_and_shade(
|
|
mode_name: str,
|
|
expected_shade: int,
|
|
) -> None:
|
|
context = _context()
|
|
context["capabilities"].append(
|
|
{
|
|
"id": "ambient_light.set_power",
|
|
"domain": "ambient_light",
|
|
"target_scopes": ["vehicle"],
|
|
"parameters": [
|
|
{
|
|
"name": "state",
|
|
"type": "string",
|
|
"allowed_values": ["on", "off"],
|
|
"required": True,
|
|
}
|
|
],
|
|
}
|
|
)
|
|
|
|
fallbacks = scene_required_native_fallbacks(mode_name, context)
|
|
|
|
assert [item["domain"] for item in fallbacks] == [
|
|
"climate",
|
|
"ambient_light",
|
|
"roof",
|
|
]
|
|
assert fallbacks[0]["capability"] == "climate.set_temperature"
|
|
assert fallbacks[1]["capability"] == "ambient_light.set_power"
|
|
assert fallbacks[2]["capability"] == "roof.set_shade_position"
|
|
assert fallbacks[2]["parameters"] == [
|
|
{"name": "position_pct", "value": expected_shade}
|
|
]
|
|
|
|
|
|
def test_name_only_scene_default_bundle_covers_all_required_native_controls() -> None:
|
|
service = SeatDemoService()
|
|
catalog = AssistantCapabilityCatalog(service)
|
|
context = catalog.scene_composition_context(catalog.snapshot())
|
|
|
|
fallbacks = scene_required_native_fallbacks(
|
|
"星夜模式",
|
|
context,
|
|
include_default_bundle=True,
|
|
)
|
|
semantics = scene_semantic_guidance("星夜模式")
|
|
semantics["native_fallback_requests"] = fallbacks
|
|
semantics["peripheral_policy"] = {"required_refs": []}
|
|
context["scene_semantics"] = semantics
|
|
|
|
assert [(item["capability"], item["target_scope"]) for item in fallbacks] == [
|
|
("climate.set_power", "vehicle"),
|
|
("climate.set_temperature", "vehicle"),
|
|
("climate.set_fan_level", "vehicle"),
|
|
("climate.set_cycle_mode", "vehicle"),
|
|
("window.set_position", "front_left"),
|
|
("window.set_position", "front_right"),
|
|
("window.set_position", "rear_left"),
|
|
("window.set_position", "rear_right"),
|
|
("roof.set_shade_position", "vehicle"),
|
|
("seat.set_ventilation", "passenger"),
|
|
("seat.set_backrest_recline", "passenger"),
|
|
("seat.set_legrest_height", "passenger"),
|
|
("ambient_light.set_power", "vehicle"),
|
|
]
|
|
assert build_scene_output_schema(context)["properties"]["requests"]["maxItems"] == 7
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("message", "expected"),
|
|
(
|
|
("创建浪漫中秋模式", False),
|
|
("创建浪漫中秋模式,空调温度设置为25度", True),
|
|
("创建灯光主题模式", True),
|
|
("创建主驾浪漫模式", True),
|
|
("创建节日模式", False),
|
|
),
|
|
)
|
|
def test_scene_detects_whether_user_named_a_concrete_control_object(
|
|
message: str,
|
|
expected: bool,
|
|
) -> None:
|
|
assert scene_has_explicit_control_object(message, _context()) is expected
|