diff --git a/changelog.md b/changelog.md index faf4f45d..17d506fb 100644 --- a/changelog.md +++ b/changelog.md @@ -13,6 +13,7 @@ ## Developer Experience - Building packages with `py.typed` files for improved typing support +- In the construction of the inbound `Activity` instance, raw `Entity` JSON is automatically deserialized into known derived `Entity` classes such as `ProductInfo`, `Mention`, `AIEntity`, and more. # Microsoft 365 Agents SDK for Python - Release Notes v1.3.0 diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index 224c43fb..54b49fab 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -13,6 +13,7 @@ from pydantic import ( Field, SerializeAsAny, + field_validator, model_serializer, model_validator, SerializerFunctionWrapHandler, @@ -38,6 +39,7 @@ ProductInfo, SensitivityUsageInfo, ) +from .entity._validate_known_entities import _validate_known_entities from .conversation_reference import ConversationReference from .text_highlight import TextHighlight from .semantic_action import SemanticAction @@ -198,6 +200,11 @@ class Activity(AgentsModel): semantic_action: SemanticAction = None caller_id: NonEmptyString = None + @field_validator("entities", mode="before") + @classmethod + def _deserialize_known_entities(cls, entities: Any) -> list[Entity]: + return _validate_known_entities(entities) + @model_validator(mode="wrap") @classmethod def _validate_channel_id( diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/__init__.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/__init__.py index 80259ccf..c8ce5be1 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/__init__.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/__init__.py @@ -19,6 +19,7 @@ from .product_info import ProductInfo from .stream_info import StreamInfo from .thing import Thing +from ._validate_known_entities import _validate_known_entities __all__ = [ "Entity", @@ -38,4 +39,5 @@ "Thing", "ActivityTreatment", "ActivityTreatmentTypes", + "_validate_known_entities", ] diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_validate_known_entities.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_validate_known_entities.py new file mode 100644 index 00000000..e231067d --- /dev/null +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/_validate_known_entities.py @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Any + +from .activity_treatment import ActivityTreatment +from .ai_entity import AIEntity +from .entity import Entity +from .entity_types import EntityTypes +from .geo_coordinates import GeoCoordinates +from .mention import Mention +from .place import Place +from .product_info import ProductInfo +from .stream_info import StreamInfo +from .thing import Thing + +_KNOWN_ENTITY_TYPES: dict[str, tuple[str, type[Entity]]] = { + EntityTypes.ACTIVITY_TREATMENT.value.casefold(): ( + EntityTypes.ACTIVITY_TREATMENT.value, + ActivityTreatment, + ), + EntityTypes.AI_CITATION.value.casefold(): ( + EntityTypes.AI_CITATION.value, + AIEntity, + ), + EntityTypes.GEO_COORDINATES.value.casefold(): ( + EntityTypes.GEO_COORDINATES.value, + GeoCoordinates, + ), + EntityTypes.MENTION.value.casefold(): (EntityTypes.MENTION.value, Mention), + EntityTypes.PLACE.value.casefold(): (EntityTypes.PLACE.value, Place), + EntityTypes.PRODUCT_INFO.value.casefold(): ( + EntityTypes.PRODUCT_INFO.value, + ProductInfo, + ), + EntityTypes.STREAM_INFO.value.casefold(): ( + EntityTypes.STREAM_INFO.value, + StreamInfo, + ), + EntityTypes.THING.value.casefold(): (EntityTypes.THING.value, Thing), +} + + +def _validate_known_entities(entities: Any) -> list[Entity]: + """Deserialize known activity entities while preserving unknown entity types. + + :param entities: The data to validate and deserialize into known entity types. + :returns: A list of validated entities, with known types deserialized into their respective classes + :raises ValueError: If the input is not a list or tuple, or if an entity is not a dict or Entity instance. + """ + if entities is None: + return [] + + if not isinstance(entities, (list, tuple)): + raise ValueError("entities must be a list or tuple") + + validated_entities: list[Entity] = [] + for entity in entities: + if isinstance(entity, Entity): + validated_entities.append(entity) + continue + + if not isinstance(entity, dict): + raise ValueError(f"entity must be a dict or Entity, got {type(entity)}") + + entity_type = entity.get("type") + if not entity_type: + raise ValueError( + "entity must have a 'type' field. Cannot infer entity type from data." + ) + + known_entity = ( + _KNOWN_ENTITY_TYPES.get(entity_type.casefold()) + if isinstance(entity_type, str) + else None + ) + if known_entity: + canonical_type, entity_cls = known_entity + entity = {**entity, "type": canonical_type} + else: + entity_cls = Entity + + validated_entities.append(entity_cls.model_validate(entity)) + + return validated_entities diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity_types.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity_types.py index 66b3faf5..3a054efc 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity_types.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity_types.py @@ -8,6 +8,7 @@ class EntityTypes(str, Enum): """Well-known enumeration of entity types.""" ACTIVITY_TREATMENT = "activityTreatment" + AI_CITATION = "https://schema.org/Message" GEO_COORDINATES = "GeoCoordinates" MENTION = "mention" PLACE = "Place" diff --git a/tests/activity/entity/test_validate_known_entities.py b/tests/activity/entity/test_validate_known_entities.py new file mode 100644 index 00000000..5ee39d64 --- /dev/null +++ b/tests/activity/entity/test_validate_known_entities.py @@ -0,0 +1,252 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest +from pydantic import ValidationError + +from microsoft_agents.activity import ( + Activity, + ActivityTreatment, + AIEntity, + Entity, + GeoCoordinates, + Mention, + Place, + ProductInfo, + StreamInfo, + Thing, +) + + +@pytest.mark.parametrize( + ("entity_data", "expected_type"), + [ + ( + {"type": "activityTreatment", "treatment": "targeted"}, + ActivityTreatment, + ), + ({"type": "GeoCoordinates", "latitude": 47.6}, GeoCoordinates), + ({"type": "mention", "text": "Ada"}, Mention), + ({"type": "Place", "name": "Seattle"}, Place), + ({"type": "ProductInfo", "id": "copilot"}, ProductInfo), + ({"type": "streaminfo", "streamSequence": 1}, StreamInfo), + ({"type": "Thing", "name": "Document"}, Thing), + ( + { + "type": "https://schema.org/Message", + "@type": "Message", + "@context": "https://schema.org", + }, + AIEntity, + ), + ], +) +def test_activity_deserializes_known_entity_types(entity_data, expected_type): + activity = Activity.model_validate( + { + "type": "message", + "entities": [entity_data], + } + ) + + assert isinstance(activity.entities[0], expected_type) + + +@pytest.mark.parametrize( + ("entity_data", "expected_type", "canonical_type"), + [ + ({"type": "MENTION"}, Mention, "mention"), + ( + {"type": "ACTIVITYTREATMENT", "treatment": "targeted"}, + ActivityTreatment, + "activityTreatment", + ), + ({"type": "PRODUCTINFO", "id": "copilot"}, ProductInfo, "ProductInfo"), + ({"type": "STREAMINFO", "streamSequence": 1}, StreamInfo, "streaminfo"), + ( + {"type": "HTTPS://SCHEMA.ORG/MESSAGE"}, + AIEntity, + "https://schema.org/Message", + ), + ], +) +def test_activity_matches_known_entity_types_case_insensitively( + entity_data, expected_type, canonical_type +): + activity = Activity.model_validate( + { + "type": "message", + "entities": [entity_data], + } + ) + + assert isinstance(activity.entities[0], expected_type) + assert activity.entities[0].type == canonical_type + + +def test_activity_preserves_unknown_entity_types(): + activity = Activity.model_validate( + { + "type": "message", + "entities": [ + { + "type": "customEntity", + "customValue": "value", + } + ], + } + ) + + entity = activity.entities[0] + assert type(entity) is Entity + assert entity.additional_properties == {"custom_value": "value"} + + +def test_activity_deserializes_schema_message_as_ai_entity_without_additional_type(): + activity = Activity.model_validate( + { + "type": "message", + "entities": [ + { + "type": "https://schema.org/Message", + "@type": "Message", + "@context": "https://schema.org", + } + ], + } + ) + + assert isinstance(activity.entities[0], AIEntity) + assert activity.entities[0].additional_type == ["AIGeneratedContent"] + + +def test_activity_preserves_existing_entity_instances(): + mention = Mention(text="Ada") + + activity = Activity(type="message", entities=[mention]) + + assert activity.entities[0] is mention + + +def test_activity_normalizes_null_entities_to_empty_list(): + activity = Activity.model_validate( + { + "type": "message", + "entities": None, + } + ) + + assert activity.entities == [] + + +@pytest.mark.parametrize("entities", ["mention", {"type": "mention"}]) +def test_activity_rejects_invalid_entities_collection(entities): + with pytest.raises(ValidationError, match="entities must be a list or tuple"): + Activity.model_validate( + { + "type": "message", + "entities": entities, + } + ) + + +@pytest.mark.parametrize("entity", [None, "mention", 1]) +def test_activity_rejects_invalid_entity_items(entity): + with pytest.raises(ValidationError, match="entity must be a dict or Entity"): + Activity.model_validate( + { + "type": "message", + "entities": [entity], + } + ) + + +@pytest.mark.parametrize("entity", [{}, {"text": "Ada"}, {"type": ""}]) +def test_activity_rejects_entities_without_type(entity): + with pytest.raises(ValidationError, match="entity must have a 'type' field"): + Activity.model_validate( + { + "type": "message", + "entities": [entity], + } + ) + + +def test_activity_accepts_entity_tuple(): + activity = Activity.model_validate( + { + "type": "message", + "entities": ({"type": "mention", "text": "Ada"},), + } + ) + + assert activity.entities == [Mention(text="Ada")] + + +def test_activity_rejects_invalid_known_entity(): + with pytest.raises(ValidationError): + Activity.model_validate( + { + "type": "message", + "entities": [{"type": "activityTreatment"}], + } + ) + + +def test_activity_entities_round_trip_preserves_typed_models(): + activity = Activity.model_validate( + { + "type": "message", + "channelId": "msteams:copilot", + "entities": [ + {"type": "activityTreatment", "treatment": "targeted"}, + { + "type": "https://schema.org/Message", + "@type": "Message", + "@context": "https://schema.org", + "citation": [ + { + "@type": "Claim", + "position": 1, + "appearance": {"name": "SDK documentation"}, + } + ], + }, + { + "type": "GeoCoordinates", + "latitude": 47.6, + "longitude": -122.3, + }, + { + "type": "mention", + "mentioned": {"id": "user-id", "name": "Ada"}, + "text": "Ada", + }, + {"type": "Place", "name": "Seattle"}, + {"type": "ProductInfo", "id": "copilot"}, + { + "type": "streaminfo", + "streamSequence": 1, + "streamId": "stream-id", + }, + {"type": "Thing", "name": "Document"}, + {"type": "customEntity", "customValue": "value"}, + ], + } + ) + + serialized = activity.model_dump(mode="json", by_alias=True, exclude_none=True) + round_tripped = Activity.model_validate(serialized) + + assert round_tripped == activity + assert [type(entity) for entity in round_tripped.entities] == [ + ActivityTreatment, + AIEntity, + GeoCoordinates, + Mention, + Place, + ProductInfo, + StreamInfo, + Thing, + Entity, + ] diff --git a/tests/activity/pydantic/test_activity_io.py b/tests/activity/pydantic/test_activity_io.py index dfcb9fcc..907b4589 100644 --- a/tests/activity/pydantic/test_activity_io.py +++ b/tests/activity/pydantic/test_activity_io.py @@ -69,7 +69,7 @@ def test_channel_id_validate_without_product_info(self): channel_id="parent:misc", entities=[ Entity(type="some_entity"), - Entity(type=EntityTypes.PRODUCT_INFO, id="misc"), + ProductInfo(id="misc"), ], ), ], diff --git a/tests/activity/test_activity.py b/tests/activity/test_activity.py index 1fecd29d..f073f921 100644 --- a/tests/activity/test_activity.py +++ b/tests/activity/test_activity.py @@ -5,6 +5,7 @@ Activity, ActivityTypes, Entity, + EntityTypes, Mention, ResourceResponse, ChannelAccount, @@ -414,11 +415,15 @@ def test_get_mentions(self): activity = Activity( type="message", entities=[ - Mention(text="Hello"), - Entity(type="other"), - Entity(type="mention", text="Another mention"), + {"type": "mention", "text": "Hello"}, + {"type": "other"}, + {"type": "mention", "text": "Another mention"}, ], ) + assert isinstance(activity.entities[0], Mention) + assert type(activity.entities[1]) is Entity + assert isinstance(activity.entities[2], Mention) + mentions = activity.get_mentions() assert mentions == [ Mention(text="Hello"), @@ -430,12 +435,9 @@ def test_get_mentions(self): [ [ [ - Entity( - type="ProductInfo", - id="product_123", - ), - Entity(type="other"), - Entity(type="mention", text="Another mention"), + {"type": "ProductInfo", "id": "product_123"}, + {"type": "other"}, + {"type": "mention", "text": "Another mention"}, ], ProductInfo( id="product_123", @@ -443,22 +445,16 @@ def test_get_mentions(self): ], [ [ - Entity(type="other"), - Entity(type="mention", text="Another mention"), + {"type": "other"}, + {"type": "mention", "text": "Another mention"}, ], None, ], [ [ - Entity( - type="ProductInfo", - id="product_123", - ), - Entity( - type="ProductInfo", - id="product_456", - ), - Entity(type="mention", text="Another mention"), + {"type": "ProductInfo", "id": "product_123"}, + {"type": "ProductInfo", "id": "product_456"}, + {"type": "mention", "text": "Another mention"}, ], ProductInfo(id="product_123"), ], @@ -467,6 +463,10 @@ def test_get_mentions(self): ) def test_get_product_info_entity_single(self, entities, expected): activity = Activity(type="message", entities=entities) + for entity in activity.entities: + if entity.type.casefold() == EntityTypes.PRODUCT_INFO.value.casefold(): + assert isinstance(entity, ProductInfo) + retrieved_product_info = activity.get_product_info_entity() assert retrieved_product_info == expected