8000 add choice_factory and its tests · umarfarook882/botbuilder-python@8c9d546 · GitHub
[go: up one dir, main page]

Skip to content

Commit 8c9d546

Browse files
committed
add choice_factory and its tests
1 parent f3a4ad3 commit 8c9d546

File tree

4 files changed

+281
-3
lines changed

4 files changed

+281
-3
lines changed

libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,20 @@
1212
Choice, FindChoicesOptions,
1313
ModelResult, FoundValue,
1414
SortedValue, Find,
15-
FindValuesOptions)
15+
FindValuesOptions,
16+
ChoiceFactory, ChoiceFactoryOptions)
1617

17-
__all__ = ['Channel', 'Choice', 'FindChoicesOptions', 'ModelResult', 'Tokenizer', 'TokenizerFunction',
18-
'Token', 'FoundValue', 'SortedValue', 'Find', 'FindValuesOptions', '__version__']
18+
__all__ = ['Channel',
19+
'Choice',
20+
'FindChoicesOptions',
21+
'ModelResult',
22+
'Tokenizer',
23+
'TokenizerFunction',
24+
'Token',
25+
'FoundValue',
26+
'SortedValue',
27+
'Find',
28+
'FindValuesOptions',
29+
'ChoiceFactory',
30+
'ChoiceFactoryOptions',
31+
'__version__']

libraries/botbuilder-dialogs/botbuilder/dialogs/choices/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77

88
from .channel import Channel
99
from .tokenizer import Tokenizer, TokenizerFunction, Token
10+
from .choice_factory import ChoiceFactory, ChoiceFactoryOptions
1011
from .choices import Choice, FoundChoice
1112
from .model_result import ModelResult
1213
from .find import Find, FindValuesOptions, FindChoicesOptions
1314
from .values import FoundValue, SortedValue
1415

1516
__all__ = ['Channel', 'Choice', 'FoundChoice',
17+
'ChoiceFactory', 'ChoiceFactoryOptions',
1618
'FindChoicesOptions', 'ModelResult', 'Tokenizer',
1719
'TokenizerFunction', 'Token', 'FoundValue',
1820
'SortedValue', 'Find', 'FindValuesOptions']
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
from typing import List, Union
5+
from botbuilder.core import MessageFactory
6+
from botbuilder.schema import ActionTypes, Activity, CardAction, InputHints
7+
8+
from .choices import Choice
9+
from .channel import Channel
10+
11+
12+
class ChoiceFactoryOptions:
13+
def __init__(self, inline_separator: str = ", ", inline_or: str = " or ",
14+
inline_or_more: str = ", or ", include_numbers: bool = True):
15+
"""Additional options used to tweak the formatting of choice lists.
16+
:param inline_separator:
17+
:param inline_or:
18+
:param inline_or_more:
19+
:param include_numbers:
20+
"""
21+
22+
"""(Optional) character used to separate individual choices when there are more than 2 choices.
23+
The default value is `", "`."""
24+
self.inline_separator = inline_separator
25+
26+
"""(Optional) separator inserted between the choices when their are only 2 choices. The default
27+
value is `" or "`."""
28+
self.inline_or = inline_or
29+
30+
"""(Optional) separator inserted between the last 2 choices when their are more than 2 choices.
31+
The default value is `", or "`."""
32+
self.inline_or_more = inline_or_more
33+
34+
"""(Optional) if `true`, inline and list style choices will be prefixed with the index of the
35+
choice as in "1. choice". If `false`, the list style will use a bulleted list instead. The
36+
default value is `true`."""
37+
self.include_numbers = include_numbers
38+
39+
40+
class ChoiceFactory:
41+
"""A set of utility functions to assist with the formatting a 'message' activity containing a list
42+
of choices.
43+
"""
44+
45+
@staticmethod
46+
def for_channel(channel_id: str, choices: List[Choice] = None,
47+
text: str = None, speak: str = None,
48+
options: ChoiceFactoryOptions = None) -> Activity:
49+
"""
50+
Returns a 'message' activity containing a list of choices that has been automatically
51+
formatted based on the capabilities of a given channel.
52+
53+
@remarks
54+
The algorithm prefers to format the supplied list of choices as suggested actions but can
55+
decide to use a text based list if suggested actions aren't natively supported by the
56+
channel, there are too many choices for the channel to display, or the title of any choice
57+
is too long.
58+
59+
If the algorithm decides to use a list it will use an inline list if there are 3 or less
60+
choices and all have short titles. Otherwise, a numbered list is used.
61+
:param channel_id:
62+
:param choices:
63+
:param text:
64+
:param speak:
65+
:param options:
66+
:return:
67+
"""
68+
69+
# Find maximum title length
70+
max_title_length = 0
71+
for choice in choices:
72+
length = (len(choice.action.title) if choice.action is not None and choice.action.title is not None else
73+
len(choice.value))
74+
if length > max_title_length:
75+
max_title_length = length
76+
77+
# Determine list style
78+
supports_suggested_actions = Channel.supports_suggested_actions(channel_id, len(choices))
79+
supports_card_actions = Channel.supports_card_actions(channel_id, len(choices))
80+
max_action_title_length = Channel.max_action_title_length(channel_id)
81+
has_message_feed = Channel.has_message_feed(channel_id)
82+
long_titles = max_title_length > max_action_title_length
83+
84+
if not long_titles and (supports_suggested_actions or (not has_message_feed and supports_card_actions)):
85+
# We always prefer showing choices using suggested actions. If the titles are too long, however,
86+
# we'll have to show them as a text list.
87+
return ChoiceFactory.suggested_action(choices, text, speak)
88+
elif not long_titles and len(choices) <= 3:
89+
# If the titles are short and there are 3 or less choices we'll use an inline list.
90+
return ChoiceFactory.inline(choices, text, speak, options)
91+
else:
92+
return ChoiceFactory.list(choices, text, speak, options)
93+
94+
@staticmethod
95+
def inline(choices: List[Choice], text: str = '',
96+
speak: str = '', options: ChoiceFactoryOptions = ChoiceFactoryOptions()) -> Activity:
97+
"""Returns a 'message' activity containing a list of choices that has been formatted as an
98+
inline list.
99+
100+
@remarks
101+
This example generates a message text of "Pick a color: (1. red, 2. green, or 3. blue)":
102+
:param choices:
103+
:param text:
104+
:param speak:
105+
:param options:
106+
:return:
107+
"""
108+
choices = choices or []
109+
110+
# Format list of choices.
111+
connector = ''
112+
txt = text or ''
113+
txt += ' '
114+
115+
for index, choice in enumerate(choices):
116+
title = (choice.action.title if hasattr(choice.action, 'title') and choice.action.title is not None else
117+
choice.value)
118+
txt += connector
119+
if options.include_numbers:
120+
txt += f"({str(index +1)}) "
121+
txt += f"{title}"
122+
if index == (len(choices) - 2):
123+
connector = options.inline_or if index == 0 else options.inline_or_more
124+
connector = connector or ''
125+
else:
126+
connector = options.inline_separator or ''
127+
txt += ""
128+
129+
# Return activity with choices as an inline list.
130+
return MessageFactory.text(txt, speak, InputHints.expecting_input)
131+
132+
@staticmethod
133+
def list(choices: List[Choice], text: str = None,
134+
speak: str = None, options: ChoiceFactoryOptions = ChoiceFactoryOptions()) -> Activity:
135+
"""Returns a 'message' activity containing a list of choices that has been formatted as an
136+
numbered or bulleted list.
137+
138+
:param choices:
139+
:param text:
140+
:param speak:
141+
:param options:
142+
:return:
143+
"""
144+
145+
# Format list of choices.
146+
connector = ''
147+
txt = text or ''
148+
txt += '\n\n '
149+
for index, choice in enumerate(choices):
150+
title = (choice.action.title if hasattr(choice.action, 'title') and choice.action.title is not None else
151+
choice.value)
152+
txt += f"{connector}{str(index + 1) + '. ' if options.include_numbers else '- '}{title}"
153+
connector = '\n '
154+
155+
# Return activity with choices as a numbered list.
156+
return MessageFactory.text(txt, speak, InputHints.expecting_input)
157+
158+
@staticmethod
159+
def suggested_action(choices: List[Choice], text: str = '', speak: str = None) -> Activity:
160+
"""Returns a 'message' activity containing a list of choices that have been added as suggested actions.
161+
:param choices:
162+
:param text:
163+
:param speak:
164+
:return:
165+
"""
166+
# Map choices to actions
167+
actions = []
168+
for choice in choices:
169+
if choice.action:
170+
actions.append(choice.action)
171+
else:
172+
actions.append(CardAction(value=choice.value,
173+
title=choice.value,
174+
type=ActionTypes.im_back))
175+
176+
return MessageFactory.suggested_actions(actions, text, speak, InputHints.expecting_input)
177+
178 17AE +
@staticmethod
179+
def to_choices(choices: List[Union[str, Choice]]) -> List[Choice]:
180+
"""
181+
Takes a mixed list of `string` and `Choice` based choices and returns them as a `List[Choice]`.
182+
Will raise a TypeError if it finds a choice that is not a str or an instance of Choice.
183+
:param choices:
184+
:return:
185+
"""
186+
prepared_choices = []
187+
188+
for (idx, choice) in enumerate(choices):
189+
# If choice is a str, convert it to a Choice.
190+
if type(choice) == str:
191+
prepared_choices.append(Choice(choice))
192+
193+
# If the choice is an instance of Choice, do nothing.
194+
elif isinstance(choice, Choice):
195+
prepared_choices.append(choice)
196+
else:
197+
raise TypeError(f'ChoiceFactory.to_choices(): choice at index {idx} is not of type str or instance of '
198+
'Choice.')
199+
200+
return prepared_choices
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
from botbuilder.dialogs import Choice, ChoiceFactory
5+
from botbuilder.schema import Activity, InputHints, ActionTypes, CardAction, SuggestedActions
6+
7+
8+
def assert_activity(received: Activity, expected: Activity):
9+
assert received.input_hint == expected.input_hint, f"Returned input_hint is invalid, [{received.input_hint}] != " \
10+
f"[{expected.input_hint}]"
11+
if expected.text:
12+
assert received.text == expected.text
13+
if expected.suggested_actions:
14+
assert received.suggested_actions.actions is not None, 'suggested_Actions.actions not found.'
15+
assert len(received.suggested_actions.actions) == \
16+
len(expected.suggested_actions.actions), f"Invalid amount of actions returned " \
17+
f"[{len(received.suggested_actions.actions)}] != " \
18+
f"[{len(expected.suggested_actions.actions)}]"
19+
F438 for index, e_action in enumerate(expected.suggested_actions.actions):
20+
r_action = received.suggested_actions.actions[index]
21+
assert e_action.type == r_action.type, f"Invalid type for action [{index}]"
22+
assert e_action.value == r_action.value, f"Invalid value for action [{index}]"
23+
assert e_action.title == r_action.title, f"Invalid title for action [{index}]"
24+
25+
26+
COLOR_CHOICES = [Choice(value='red'), Choice(value='green'), Choice(value='blue')]
27+
28+
29+
EXPECTED_ACTIONS = SuggestedActions(actions=[
30+
CardAction(type=ActionTypes.im_back,
31+
value='red',
32+
title='red'),
33+
CardAction(type=ActionTypes.im_back,
34+
value='green',
35+
title='green'),
36+
CardAction(type=ActionTypes.im_back,
37+
value='blue',
38+
title='blue')
39+
])
40+
41+
42+
class TestChoiceFactory:
43+
def test_should_render_choices_inline(self):
44+
activity = ChoiceFactory.inline(COLOR_CHOICES, 'select from:')
45+
assert_activity(activity, Activity(text='select from: (1) red, (2) green, or (3) blue',
46+
input_hint=InputHints.expecting_input))
47+
48+
def test_should_render_choices_as_a_list(self):
49+
activity = ChoiceFactory.list(COLOR_CHOICES, 'select from:')
50+
assert_activity(activity, Activity(text='select from:\n\n 1. red\n 2. green\n 3. blue',
51+
input_hint=InputHints.expecting_input))
52+
53+
def test_should_render_choices_as_suggested_actions(self):
54+
activity = ChoiceFactory.suggested_action(COLOR_CHOICES, 'select from:')
55+
assert_activity(activity, Activity(text='select from:',
56+
input_hint=InputHints.expecting_input,
57+
suggested_actions=EXPECTED_ACTIONS))
58+
59+
def test_should_automatically_choose_render_style_based_on_channel_type(self):
60+
activity = ChoiceFactory.for_channel('emulator', COLOR_CHOICES, 'select from:')
61+
assert_activity(activity, Activity(text='select from:',
62+
input_hint=InputHints.expecting_input,
63+
suggested_actions=EXPECTED_ACTIONS))

0 commit comments

Comments
 (0)
0