Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,40 +24,39 @@
from .conversation_resource_response import ConversationResourceResponse
from .conversations_result import ConversationsResult
from .expected_replies import ExpectedReplies
from .entity import Entity
from .ai_entity import (
from .entity import (
Entity,
AIEntity,
ClientCitation,
ClientCitationAppearance,
ClientCitationImage,
ClientCitationIconName,
Mention,
SensitivityUsageInfo,
SensitivityPattern,
add_ai_to_activity,
GeoCoordinates,
Place,
Thing,
)
from .error import Error
from .error_response import ErrorResponse
from .fact import Fact
from .geo_coordinates import GeoCoordinates
from .hero_card import HeroCard
from .inner_http_error import InnerHttpError
from .invoke_response import InvokeResponse
from .media_card import MediaCard
from .media_event_value import MediaEventValue
from .media_url import MediaUrl
from .mention import Mention
from .message_reaction import MessageReaction
from .oauth_card import OAuthCard
from .paged_members_result import PagedMembersResult
from .place import Place
from .receipt_card import ReceiptCard
from .receipt_item import ReceiptItem
from .resource_response import ResourceResponse
from .semantic_action import SemanticAction
from .signin_card import SigninCard
from .suggested_actions import SuggestedActions
from .text_highlight import TextHighlight
from .thing import Thing
from .thumbnail_card import ThumbnailCard
from .thumbnail_url import ThumbnailUrl
from .token_exchange_invoke_request import TokenExchangeInvokeRequest
Expand Down Expand Up @@ -92,7 +91,6 @@
from .conversation_update_types import ConversationUpdateTypes
from .message_update_types import MessageUpdateTypes


from .channel_adapter_protocol import ChannelAdapterProtocol
from .turn_context_protocol import TurnContextProtocol
from ._load_configuration import load_configuration_from_env
Expand Down Expand Up @@ -131,7 +129,6 @@
"ClientCitationIconName",
"SensitivityUsageInfo",
"SensitivityPattern",
"add_ai_to_activity",
"Error",
"ErrorResponse",
"Fact",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from abc import ABC
from typing import Any, Callable

from .agents_model import AgentsModel


class ModelFieldHelper(ABC):
"""Base class for model field processing prior to initialization of an AgentsModel"""

def process(self, key: str) -> dict[str, Any]:
"""Takes the key in the destination object and returns a dictionary of new fields to add"""
raise NotImplementedError()


class SkipIf(ModelFieldHelper):
"""Skip if the value meets the given condition."""

def __init__(self, value, skip_condition: Callable[[Any], bool]):
self.value = value
self._skip_condition = skip_condition

def process(self, key: str) -> dict[str, Any]:
if self._skip_condition(self.value):
return {}
return {key: self.value}


class SkipNone(SkipIf):
"""Skip if the value is None."""

def __init__(self, value):
super().__init__(value, lambda v: v is None)


def pick_model_dict(**kwargs):
"""Processes a list of keyword arguments, using ModelFieldHelper subclasses to determine which fields to include in the final model.

This function is useful for dynamically constructing models based on varying input data.

Usage:
activity_dict = pick_model_dict(type="message", id="123", text=SkipNone(text_variable))
activity = Activity.model_validate(activity_dict)
"""

model_dict = {}
for key, value in kwargs.items():
if not isinstance(value, ModelFieldHelper):
model_dict[key] = value
else:
model_dict.update(value.process(key))

return model_dict


def pick_model(model_class: type[AgentsModel], **kwargs) -> AgentsModel:
"""Picks model fields from the given keyword arguments.

Usage:
activity = pick_model(Activity, type="message", id="123", text=SkipNone(text_variable))
"""
return model_class(**pick_model_dict(**kwargs))
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,22 @@
from .activity_types import ActivityTypes
from .channel_account import ChannelAccount
from .conversation_account import ConversationAccount
from .mention import Mention
from .message_reaction import MessageReaction
from .resource_response import ResourceResponse
from .suggested_actions import SuggestedActions
from .attachment import Attachment
from .entity import Entity
from .entity import (
Entity,
Mention,
AIEntity,
ClientCitation,
SensitivityUsageInfo,
)
from .conversation_reference import ConversationReference
from .text_highlight import TextHighlight
from .semantic_action import SemanticAction
from .agents_model import AgentsModel
from ._model_utils import pick_model, SkipNone
from ._type_aliases import NonEmptyString


Expand Down Expand Up @@ -390,39 +396,39 @@ def create_reply(self, text: str = None, locale: str = None):
.. remarks::
The new activity sets up routing information based on this activity.
"""
return Activity(
return pick_model(
Activity,
type=ActivityTypes.message,
timestamp=datetime.now(timezone.utc),
from_property=ChannelAccount(
id=self.recipient.id if self.recipient else None,
name=self.recipient.name if self.recipient else None,
from_property=SkipNone(
ChannelAccount.pick_properties(self.recipient, ["id", "name"])
),
recipient=ChannelAccount(
id=self.from_property.id if self.from_property else None,
name=self.from_property.name if self.from_property else None,
recipient=SkipNone(
ChannelAccount.pick_properties(self.from_property, ["id", "name"])
),
reply_to_id=(
self.id
SkipNone(self.id)
if type != ActivityTypes.conversation_update
or self.channel_id not in ["directline", "webchat"]
else None
),
service_url=self.service_url,
channel_id=self.channel_id,
conversation=ConversationAccount(
is_group=self.conversation.is_group,
id=self.conversation.id,
name=self.conversation.name,
conversation=SkipNone(
ConversationAccount.pick_properties(
self.conversation, ["is_group", "id", "name"]
)
),
text=text if text else "",
locale=locale if locale else self.locale,
locale=locale if locale else SkipNone(self.locale),
attachments=[],
entities=[],
)

def create_trace(
self, name: str, value: object = None, value_type: str = None, label: str = None
):
# robrandao: TODO -> needs to handle Nones like create_reply
"""
Creates a new trace activity based on this activity.

Expand All @@ -434,42 +440,42 @@ def create_trace(
:returns: The new trace activity.
"""
if not value_type and value:
value_type = type(value)
value_type = type(value).__name__

return Activity(
return pick_model(
Activity,
type=ActivityTypes.trace,
timestamp=datetime.now(timezone.utc),
from_property=ChannelAccount(
id=self.recipient.id if self.recipient else None,
name=self.recipient.name if self.recipient else None,
from_property=SkipNone(
ChannelAccount.pick_properties(self.recipient, ["id", "name"])
),
recipient=ChannelAccount(
id=self.from_property.id if self.from_property else None,
name=self.from_property.name if self.from_property else None,
recipient=SkipNone(
ChannelAccount.pick_properties(self.from_property, ["id", "name"])
),
reply_to_id=(
self.id
SkipNone(self.id) # preserve unset
if type != ActivityTypes.conversation_update
or self.channel_id not in ["directline", "webchat"]
else None
),
service_url=self.service_url,
channel_id=self.channel_id,
conversation=ConversationAccount(
is_group=self.conversation.is_group,
id=self.conversation.id,
name=self.conversation.name,
conversation=SkipNone(
ConversationAccount.pick_properties(
self.conversation, ["is_group", "id", "name"]
)
),
name=name,
label=label,
value_type=value_type,
value=value,
name=SkipNone(name),
label=SkipNone(label),
value_type=SkipNone(value_type),
value=SkipNone(value),
).as_trace_activity()

@staticmethod
def create_trace_activity(
name: str, value: object = None, value_type: str = None, label: str = None
):
# robrandao: TODO -> SkipNone
"""
Creates an instance of the :class:`Activity` class as a TraceActivity object.

Expand All @@ -481,14 +487,15 @@ def create_trace_activity(
:returns: The new trace activity.
"""
if not value_type and value:
value_type = type(value)
value_type = type(value).__name__

return Activity(
return pick_model(
Activity,
type=ActivityTypes.trace,
name=name,
label=label,
value_type=value_type,
value=value,
label=SkipNone(label),
value_type=SkipNone(value_type),
value=SkipNone(value),
)

@staticmethod
Expand All @@ -506,10 +513,10 @@ def get_conversation_reference(self) -> ConversationReference:

:returns: A conversation reference for the conversation that contains this activity.
"""

return ConversationReference(
return pick_model(
ConversationReference,
activity_id=(
self.id
SkipNone(self.id)
if self.type != ActivityTypes.conversation_update
or self.channel_id not in ["directline", "webchat"]
else None
Expand All @@ -532,8 +539,9 @@ def get_mentions(self) -> list[Mention]:
This method is defined on the :class:`Activity` class, but is only intended for use with a message activity,
where the activity Activity.Type is set to ActivityTypes.Message.
"""
_list = self.entities
return [x for x in _list if str(x.type).lower() == "mention"]
if not self.entities:
return []
return [x for x in self.entities if x.type.lower() == "mention"]

def get_reply_conversation_reference(
self, reply: ResourceResponse
Expand Down Expand Up @@ -596,7 +604,7 @@ def __is_activity(self, activity_type: str) -> bool:
if self.type is None:
return False

type_attribute = str(self.type).lower()
type_attribute = f"ActivityTypes.{str(self.type)}".lower()
activity_type = str(activity_type).lower()

result = type_attribute.startswith(activity_type)
Expand All @@ -611,3 +619,32 @@ def __is_activity(self, activity_type: str) -> bool:
)

return result

def add_ai_metadata(
self,
citations: Optional[list[ClientCitation]] = None,
usage_info: Optional[SensitivityUsageInfo] = None,
) -> None:
"""
Adds AI entity to an activity to indicate AI-generated content.

Args:
activity: The activity to modify
citations: Optional list of citations
usage_info: Optional sensitivity usage information
"""
if citations:
ai_entity = AIEntity(
type="https://schema.org/Message",
schema_type="Message",
context="https://schema.org",
id="",
additional_type=["AIGeneratedContent"],
citation=citations,
usage_info=usage_info,
)

if self.entities is None:
self.entities = []

self.entities.append(ai_entity)
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel

Expand All @@ -16,3 +18,29 @@ def _serialize(self):

return {k: v for k, v in self if k not in omit_if_empty and v is not None}
"""

@classmethod
def pick_properties(cls, original: AgentsModel, fields_to_copy=None, **kwargs):
"""Picks properties from the original model and returns a new instance (of a possibly different AgentsModel) with those properties.

This method preserves unset values.

args:
original: The original model instance to copy properties from. If None, returns None.
fields_to_copy: The specific fields to copy. If None, all fields are copied.
**kwargs: Additional fields to include in the new instance.
"""
if not original:
return None

if fields_to_copy is None:
fields_to_copy = original.model_fields_set
else:
fields_to_copy = original.model_fields_set & set(fields_to_copy)

dest = {}
for field in fields_to_copy:
dest[field] = getattr(original, field)

dest.update(kwargs)
return cls.model_validate(dest)
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class AnimationCard(AgentsModel):

title: NonEmptyString = None
subtitle: NonEmptyString = None
text: NonEmptyString = None
text: str = None
image: ThumbnailUrl = None
media: list[MediaUrl] = None
buttons: list[CardAction] = None
Expand Down
Loading