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 @@ -1264,22 +1264,20 @@ def add_ai_metadata(
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(
id="",
additional_type=["AIGeneratedContent"],
citation=citations,
usage_info=usage_info,
)
ai_entity = AIEntity(
id="",
additional_type=["AIGeneratedContent"],
citation=citations,
usage_info=usage_info,
)

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

self.entities.append(ai_entity)
self.entities.append(ai_entity)

def is_agentic_request(self) -> bool:
return self.recipient and self.recipient.role in [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import uuid
import asyncio
import logging
from collections.abc import Mapping
from typing import Optional, Callable, Literal, cast, TYPE_CHECKING

from microsoft_agents.activity import (
Expand Down Expand Up @@ -499,6 +500,12 @@ def _create_final_message(self, *, include_stream_info: bool) -> Activity:
)
if self._should_update_final_activity() and self._stream_id:
activity.id = self._stream_id

if self._enable_feedback_loop and self._is_teams_channel():
self._add_feedback_loop_to_channel_data(activity)

self._add_generated_by_ai_metadata(activity)

return activity

def _create_stream_stopped_message(self, message: str) -> Activity:
Expand All @@ -514,6 +521,7 @@ def _create_stream_stopped_message(self, message: str) -> Activity:
)
],
)
self._add_generated_by_ai_metadata(activity)
return activity

def _create_stream_timed_out_message(
Expand All @@ -534,12 +542,15 @@ def _create_stream_timed_out_message(
stream_info.stream_id = self._stream_id
entities.append(stream_info)

return Activity(
activity = Activity(
id=self._stream_id,
type="message",
text=text,
entities=entities,
)
if add_stream_final:
self._add_generated_by_ai_metadata(activity)
return activity

def _create_stream_timed_out_streaming_update(self) -> Activity:
text = (
Expand Down Expand Up @@ -615,23 +626,6 @@ async def _send_activity(
)
)

# Add in Powered by AI feature flags
if self._ended:
if self._enable_feedback_loop and self._feedback_loop_type:
# Add feedback loop to streaminfo entity
if streaminfo_entity:
streaminfo_entity.feedback_loop = {"type": self._feedback_loop_type}
else:
# Add feedback loop enabled to streaminfo entity
if streaminfo_entity:
streaminfo_entity.feedback_loop_enabled = self._enable_feedback_loop
# Add in Generated by AI
if self._enable_generated_by_ai_label:
curr_citations = CitationUtil.get_used_citations(
self._message, self._citations
)
activity.add_ai_metadata(curr_citations, self._sensitivity_label)

try:
response = await self._context.send_activity(activity)

Expand Down Expand Up @@ -840,6 +834,38 @@ def _is_teams_channel(self) -> bool:
channel_id = self._context.activity.channel_id
return bool(channel_id) and channel_id.channel == Channels.ms_teams

def _add_feedback_loop_to_channel_data(self, activity: Activity) -> None:
channel_data = activity.channel_data
if channel_data is None:
serialized_channel_data = {}
elif isinstance(channel_data, Mapping):
serialized_channel_data = dict(channel_data)
elif callable(model_dump := getattr(channel_data, "model_dump", None)):
serialized_channel_data = model_dump(
mode="json",
by_alias=True,
exclude_none=True,
exclude_unset=True,
)
else:
logger.warning(
"Unable to add feedback loop to unsupported channel data type: %s",
type(channel_data).__name__,
)
return

serialized_channel_data["feedbackLoop"] = {
"type": self._feedback_loop_type or "default"
}
activity.channel_data = serialized_channel_data

def _add_generated_by_ai_metadata(self, activity: Activity) -> None:
if not self._enable_generated_by_ai_label:
return

curr_citations = CitationUtil.get_used_citations(self._message, self._citations)
activity.add_ai_metadata(curr_citations, self._sensitivity_label)

def _should_update_final_activity(self) -> bool:
if self._is_m365_copilot():
return False
Expand Down
10 changes: 10 additions & 0 deletions tests/activity/test_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,16 @@ def test_get_mentions(self):
Mention(text="Another mention"),
]

def test_add_ai_metadata_adds_ai_entity_without_citations(self):
activity = Activity(type=ActivityTypes.message)

activity.add_ai_metadata()

assert len(activity.entities) == 1
assert isinstance(activity.entities[0], AIEntity)
assert activity.entities[0].additional_type == ["AIGeneratedContent"]
assert activity.entities[0].citation is None

@pytest.mark.parametrize(
"entities, expected",
[
Expand Down
116 changes: 114 additions & 2 deletions tests/hosting_core/app/streaming/test_streaming_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,31 @@ async def test_timeout_notification_preserves_streamed_text(mocker):
assert stopped_activity.entities[0].stream_type == "final"


@pytest.mark.asyncio
async def test_timeout_notification_adds_generated_by_ai_metadata(mocker):
context = _create_turn_context(
mocker,
channel_id=Channels.ms_teams,
return_value=ResourceResponse(id="stream-stop"),
)
response = StreamingResponse(context)
response._interval = 0
response.set_generated_by_ai_label(True)

response.queue_text_chunk("Partial response")
await response.wait_for_queue()
assert await response.send_stream_timed_out_notification("Still working.")

stopped_activity = context.send_activity.await_args_list[-1].args[0]
ai_entities = [
entity
for entity in stopped_activity.entities
if "AIGeneratedContent" in (getattr(entity, "additional_type", None) or [])
]
assert len(ai_entities) == 1
assert ai_entities[0].citation is None


@pytest.mark.asyncio
async def test_timeout_notification_sends_m365_final_as_new_activity(mocker):
context = _create_turn_context(
Expand Down Expand Up @@ -726,9 +751,10 @@ async def send_activity(_activity):


@pytest.mark.asyncio
async def test_feedback_loop_type_added_to_final_streaminfo_entity(mocker):
async def test_feedback_loop_type_added_to_final_teams_channel_data(mocker):
context = _create_turn_context(
mocker,
channel_id=Channels.ms_teams,
delivery_mode=DeliveryModes.stream,
return_value=[ResourceResponse(id="stream-4")],
)
Expand All @@ -744,7 +770,56 @@ async def test_feedback_loop_type_added_to_final_streaminfo_entity(mocker):
entity for entity in final_activity.entities if entity.type == "streaminfo"
)

assert stream_info.feedback_loop == {"type": "custom"}
assert stream_info.feedback_loop_enabled is False
assert stream_info.feedback_loop is None

payload = final_activity.model_dump(by_alias=True, exclude_unset=True, mode="json")
stream_info_payload = next(
entity for entity in payload["entities"] if entity["type"] == "streaminfo"
)
assert "feedbackLoopEnabled" not in stream_info_payload
assert "feedbackLoop" not in stream_info_payload
assert payload["channelData"] == {"feedbackLoop": {"type": "custom"}}


def test_feedback_loop_defaults_to_default_type_and_preserves_channel_data(mocker):
context = _create_turn_context(
mocker,
channel_id=Channels.ms_teams,
)
response = StreamingResponse(context)
response.set_feedback_loop(True)
activity = Activity(
type="message",
text="feedback",
entities=[],
channel_data={"tenant": {"id": "tenant-id"}},
)

response._add_feedback_loop_to_channel_data(activity)

assert activity.channel_data == {
"tenant": {"id": "tenant-id"},
"feedbackLoop": {"type": "default"},
}


@pytest.mark.asyncio
async def test_feedback_loop_is_not_added_to_non_teams_final_message(mocker):
context = _create_turn_context(
mocker,
channel_id=Channels.webchat,
return_value=ResourceResponse(id="webchat-feedback"),
)
response = StreamingResponse(context)
response.set_feedback_loop(True)
response.set_feedback_loop_type("custom")

response.queue_text_chunk("feedback")
await response.end_stream()

final_activity = context.send_activity.await_args_list[-1].args[0]
assert final_activity.channel_data is None


@pytest.mark.asyncio
Expand All @@ -771,6 +846,42 @@ async def test_generated_by_ai_label_adds_ai_entity_on_final_message(mocker):
assert len(ai_entities) == 1


@pytest.mark.asyncio
async def test_generated_by_ai_label_without_citations_is_only_on_final_message(
mocker,
):
context = _create_turn_context(
mocker,
delivery_mode=DeliveryModes.stream,
return_value=[
ResourceResponse(id="stream-6"),
ResourceResponse(id="stream-6"),
],
)
response = StreamingResponse(context)
response.set_generated_by_ai_label(True)

response.queue_text_chunk("Generated response")
await response.wait_for_queue()

streaming_activity = context.send_activity.await_args_list[0].args[0]
assert not any(
"AIGeneratedContent" in (getattr(entity, "additional_type", None) or [])
for entity in streaming_activity.entities
)

await response.end_stream()

final_activity = context.send_activity.await_args_list[-1].args[0]
ai_entities = [
entity
for entity in final_activity.entities
if "AIGeneratedContent" in (getattr(entity, "additional_type", None) or [])
]
assert len(ai_entities) == 1
assert ai_entities[0].citation is None


@pytest.mark.asyncio
async def test_streaming_operations_with_sleeps_send_informative_and_text_updates(
mocker,
Expand Down Expand Up @@ -1020,6 +1131,7 @@ async def test_feedback_loop_type_without_enable_does_not_emit_feedback_loop_obj

assert not streaminfo.feedback_loop
assert not streaminfo.feedback_loop_enabled
assert sent.channel_data is None


@pytest.mark.asyncio
Expand Down
Loading