Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PY] feat: custom feedback form + citation changes #2275

Merged
merged 12 commits into from
Jan 30, 2025
10 changes: 7 additions & 3 deletions python/packages/ai/teams/ai/ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ async def _on_say_command(
for i, citation in enumerate(msg_context.citations):
citations.append(
ClientCitation(
position=f"{i + 1}",
position=i + 1,
appearance=Appearance(
name=citation.title or f"Document {i + 1}",
abstract=snippet(citation.content, 477),
Expand All @@ -315,10 +315,14 @@ async def _on_say_command(

# If there are citations, filter out the citations unused in content.
referenced_citations = get_used_citations(content_text, citations)
channel_data = {}
channel_data: Dict[str, Any] = {}

if is_teams_channel:
channel_data["feedbackLoopEnabled"] = self._options.enable_feedback_loop
if self._options.enable_feedback_loop and not self._options.feedback_loop_type:
channel_data["feedbackLoopEnabled"] = self._options.enable_feedback_loop

if self._options.feedback_loop_type:
channel_data["feedbackLoop"] = {"type": self._options.feedback_loop_type}

await context.send_activity(
Activity(
Expand Down
4 changes: 3 additions & 1 deletion python/packages/ai/teams/ai/ai_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Generic, TypeVar
from typing import Generic, Literal, Optional, TypeVar

from ..state import TurnState
from .moderators.default_moderator import DefaultModerator
Expand Down Expand Up @@ -40,3 +40,5 @@ class AIOptions(Generic[StateT]):
Optional. If true, the AI system will enable the feedback loop in Teams that
allows a user to give thumbs up or down to a response.
"""

feedback_loop_type: Optional[Literal["default", "custom"]] = None
12 changes: 11 additions & 1 deletion python/packages/ai/teams/ai/citations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,19 @@
from .citations import (
AIEntity,
Appearance,
AppearanceImage,
ClientCitation,
ClientCitationIconName,
Pattern,
SensitivityUsageInfo,
)

__all__ = ["ClientCitation", "Appearance", "SensitivityUsageInfo", "Pattern", "AIEntity"]
__all__ = [
"ClientCitation",
"ClientCitationIconName",
"Appearance",
"AppearanceImage",
"SensitivityUsageInfo",
"Pattern",
"AIEntity",
]
67 changes: 57 additions & 10 deletions python/packages/ai/teams/ai/citations/citations.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Optional
from typing import Literal, Optional, Union

from botbuilder.schema import Entity
from msrest.serialization import Model
Expand Down Expand Up @@ -47,11 +47,11 @@ class ClientCitation(Model):

_attribute_map = {
"type_": {"key": "@type", "type": "str"},
"position": {"key": "position", "type": "str"},
"position": {"key": "position", "type": "int"},
"appearance": {"key": "appearance", "type": "Appearance"},
}

position: str
position: int
appearance: Appearance
type_: str = field(default="Claim", metadata={"alias": "@type"}, init=False, repr=False)

Expand All @@ -63,13 +63,13 @@ class Appearance(Model):

Attributes:
@type (str): Required; must be 'DigitalDocument'
name (str): The name of the document
name (str): The name of the document. (max length 80)
text (str): Optional; the appearance text of the citation.
url (str): The url of the document
abstract (str): Content of the citation. Must be clipped if longer than 480 characters
abstract (str): Extract of the referenced content. (max length 160)
encodingFormat (str): Encoding format of the `citation.appearance.text` field.
image (str): Used for icon; for now it is ignored
keywords (list[str]): The optional keywords to the citation
image (AppearanceImage): Information about the citation’s icon.
keywords (list[str]): Optional; set by developer. (max length 3) (max keyword length 28)
usageInfo (SensitivityUsageInfo): The optional sensitivity content information
"""

Expand All @@ -82,20 +82,44 @@ class Appearance(Model):
"text": {"key": "text", "type": "str"},
"url": {"key": "url", "type": "str"},
"encoding_format": {"key": "encodingFormat", "type": "str"},
"image": {"key": "image", "type": "str"},
"image": {"key": "image", "type": "AppearanceImage"},
}

name: str
abstract: str
keywords: Optional[list[str]] = field(default=None)
text: Optional[str] = field(default=None)
url: Optional[str] = field(default=None)
image: Optional[str] = field(default=None)
encoding_format: Optional[str] = field(default=None)
image: Optional[AppearanceImage] = field(default=None)
encoding_format: Optional[
Union[
Literal["text/html"],
Literal["application/vnd.microsoft.card.adaptive"],
]
] = field(default=None)
usage_info: Optional[SensitivityUsageInfo] = field(default=None)
type_: str = field(default="DigitalDocument", metadata={"alias": "@type"})


@dataclass
class AppearanceImage(Model):
"""
Represents how the citation will be rendered

Attributes:
@type (str): Required; must be 'ImageObject'
name (str): The image/icon name
"""

_attribute_map = {
"type_": {"key": "@type", "type": "str"},
"name": {"key": "name", "type": "str"},
}

name: ClientCitationIconName
type_: str = field(default="ImageObject", metadata={"alias": "@type"})


@dataclass
class SensitivityUsageInfo(Model):
"""
Expand Down Expand Up @@ -144,3 +168,26 @@ class Pattern(Model):
name: str
term_code: str
type_: str = field(default="DefinedTerm", metadata={"alias": "@type"})


ClientCitationIconName = Union[
Literal["Microsoft Workd"],
Literal["Microsoft Excel"],
Literal["Microsoft PowerPoint"],
Literal["Microsoft Visio"],
Literal["Microsoft Loop"],
Literal["Microsoft Whiteboard"],
Literal["Adobe Illustrator"],
Literal["Adobe Photoshop"],
Literal["Adobe InDesign"],
Literal["Adobe Flash"],
Literal["Sketch"],
Literal["Source Code"],
Literal["Image"],
Literal["GIF"],
Literal["Video"],
Literal["Sound"],
Literal["ZIP"],
Literal["Text"],
Literal["PDF"],
]
10 changes: 10 additions & 0 deletions python/packages/ai/teams/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from .feedback_loop_data import FeedbackLoopData
from .meetings.meetings import Meetings
from .message_extensions.message_extensions import MessageExtensions
from .messages.messages import Messages
from .route import Route, RouteHandler
from .state import TurnState
from .task_modules import TaskModules
Expand Down Expand Up @@ -79,6 +80,7 @@ class Application(Bot, Generic[StateT]):
_message_extensions: MessageExtensions[StateT]
_task_modules: TaskModules[StateT]
_meetings: Meetings[StateT]
_messages: Messages[StateT]

def __init__(self, options: ApplicationOptions = ApplicationOptions()) -> None:
"""
Expand All @@ -96,6 +98,7 @@ def __init__(self, options: ApplicationOptions = ApplicationOptions()) -> None:
self._routes, options.task_modules.task_data_filter
)
self._meetings = Meetings[StateT](self._routes)
self._messages = Messages[StateT](self._routes)

if options.long_running_messages and (not options.adapter or not options.bot_app_id):
raise ApplicationError("""
Expand Down Expand Up @@ -189,6 +192,13 @@ def meetings(self) -> Meetings[StateT]:
"""
return self._meetings

@property
def messages(self) -> Messages[StateT]:
"""
Access the application's messages functionalities.
"""
return self._messages

def activity(
self, type: ActivityType
) -> Callable[[RouteHandler[StateT]], RouteHandler[StateT]]:
Expand Down
6 changes: 3 additions & 3 deletions python/packages/ai/teams/feedback_loop_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Literal
from typing import Any, Dict, Literal, Union

from dataclasses_json import DataClassJsonMixin, config, dataclass_json

Expand All @@ -25,7 +25,7 @@ class FeedbackLoopData(DataClassJsonMixin):
reply_to_id: str
"The activity ID that the feedback was provided on."

action_name: str = "feedback"
action_name: Literal["feedback"] = "feedback"


@dataclass_json
Expand All @@ -38,5 +38,5 @@ class FeedbackLoopActionValue(DataClassJsonMixin):
reaction: Literal["like", "dislike"]
"The reaction"

feedback: str
feedback: Union[str, Dict[str, Any]]
"The response the user provides after pressing one of the feedback buttons."
8 changes: 8 additions & 0 deletions python/packages/ai/teams/messages/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the MIT License.
"""

from .messages import Messages

__all__ = ["Messages"]
96 changes: 96 additions & 0 deletions python/packages/ai/teams/messages/messages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the MIT License.
"""

from __future__ import annotations

from typing import Awaitable, Callable, Generic, List, TypeVar, Union, cast

from botbuilder.core import InvokeResponse, TurnContext
from botbuilder.core.serializer_helper import serializer_helper
from botbuilder.schema import Activity, ActivityTypes
from botbuilder.schema.teams import (
TaskModuleContinueResponse,
TaskModuleMessageResponse,
TaskModuleResponse,
TaskModuleTaskInfo,
)

from teams.route import Route
from teams.state import TurnState

FETCH_INVOKE_NAME = "message/fetchTask"

StateT = TypeVar("StateT", bound=TurnState)


class Messages(Generic[StateT]):
_routes: List[Route[StateT]] = []

def __init__(self, routes: List[Route[StateT]]) -> None:
self._routes = routes

def fetch_task(self) -> Callable[
[Callable[[TurnContext, StateT, dict], Awaitable[Union[TaskModuleTaskInfo, str]]]],
Callable[[TurnContext, StateT, dict], Awaitable[Union[TaskModuleTaskInfo, str]]],
]:
"""
Adds a route for handling the message fetch task activity.
This method can be used as either a decorator or a method.

```python
# Use this method as a decorator
@app.messages.fetch_task()
async def fetch_task(context: TurnContext, state: TurnState, data: dict):
print(f"Execute with data: {data}")
return True

# Pass a function to this method
app.messages.fetch_task()(fetch_task)
```
"""

# Create route selector for the handler
def __selector__(context: TurnContext) -> bool:
return (
context.activity.type == ActivityTypes.invoke
and context.activity.name == FETCH_INVOKE_NAME
)

def __call__(
func: Callable[
[TurnContext, StateT, dict],
Awaitable[Union[TaskModuleTaskInfo, str]],
],
) -> Callable[
[TurnContext, StateT, dict],
Awaitable[Union[TaskModuleTaskInfo, str]],
]:
async def __invoke__(context: TurnContext, state: StateT):
res = await func(context, state, cast(dict, context.activity.value))
await self._invoke_task_response(context, res)
return True

self._routes.append(Route[StateT](__selector__, __invoke__, True))
return func

return __call__

async def _invoke_task_response(
self, context: TurnContext, body: Union[TaskModuleTaskInfo, str]
):
if context._INVOKE_RESPONSE_KEY in context.turn_state:
return

response = TaskModuleResponse(task=TaskModuleContinueResponse(value=body))

if isinstance(body, str):
response = TaskModuleResponse(task=TaskModuleMessageResponse(value=body))

await context.send_activity(
Activity(
type=ActivityTypes.invoke_response,
value=InvokeResponse(body=serializer_helper(response), status=200),
)
)
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Optional
from typing import Literal, Optional

from dataclasses_json import DataClassJsonMixin, config, dataclass_json

Expand All @@ -28,6 +28,8 @@ class StreamingChannelData(DataClassJsonMixin):
stream_id (Optional[str]): The ID of the stream.
Assigned after the initial update is sent.
feedback_loop_enabled (Optional[bool]): Whether the feedback loop is enabled.
feedback_loop_type (Optional[Literal["default", "custom"]]): the type of
feedback loop ux to use
"""

stream_type: str = field(metadata=config(field_name="streamType"))
Expand All @@ -38,3 +40,6 @@ class StreamingChannelData(DataClassJsonMixin):
feedback_loop_enabled: Optional[bool] = field(
default=None, metadata=config(field_name="feedbackLoopEnabled")
)
feedback_loop_type: Optional[Literal["default", "custom"]] = field(
default=None, metadata=config(field_name="feedbackLoopType")
)
Loading