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, ) 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