From 54b88129db4dedb5656838bd59876cba565bae63 Mon Sep 17 00:00:00 2001 From: Joffrey Bienvenu Date: Sun, 7 Feb 2021 02:41:44 +0100 Subject: [PATCH] Simple dialogs for hotel's room booking fully implemented --- main.py | 15 ++- src/bot.py | 12 +- src/dialogs/__init__.py | 5 +- src/dialogs/booking_room_dialog.py | 179 +++++++++++++++++++++++++ src/dialogs/helpers/__init__.py | 3 +- src/dialogs/helpers/nlu_helper.py | 10 ++ src/dialogs/main_dialog.py | 98 ++++++++++++++ src/dialogs/room_reservation_dialog.py | 109 --------------- src/dialogs/utils/__init__.py | 4 + src/dialogs/utils/emoji.py | 7 + src/nlu/__init__.py | 3 +- src/nlu/classifying/classifier.py | 7 +- src/nlu/intent.py | 15 +++ src/nlu/nlu.py | 13 +- 14 files changed, 350 insertions(+), 130 deletions(-) create mode 100644 src/dialogs/booking_room_dialog.py create mode 100644 src/dialogs/main_dialog.py delete mode 100644 src/dialogs/room_reservation_dialog.py create mode 100644 src/dialogs/utils/__init__.py create mode 100644 src/dialogs/utils/emoji.py create mode 100644 src/nlu/intent.py diff --git a/main.py b/main.py index 0073228..6a24ddc 100644 --- a/main.py +++ b/main.py @@ -10,7 +10,8 @@ from botbuilder.core.integration import aiohttp_error_middleware from botbuilder.schema import Activity, ActivityTypes -from src.dialogs import RoomReservationDialog +from src.dialogs import MainDialog, BookingRoomDialog +from src.nlu import NLU from src import Bot from config import Config @@ -63,9 +64,15 @@ async def on_error(context: TurnContext, error_: Exception): CONVERSATION_STATE = ConversationState(MEMORY) USER_STATE = UserState(MEMORY) -# Create main dialog and bot -DIALOG = RoomReservationDialog(USER_STATE) -bot = Bot(CONVERSATION_STATE, USER_STATE, DIALOG) +# Load the NLU recognizer +nlu = NLU() + +# Create the dialogs +dialog_room_reservation = BookingRoomDialog(nlu, USER_STATE) +dialog_main = MainDialog(nlu, USER_STATE, dialog_room_reservation) + +# Create the bot +bot = Bot(CONVERSATION_STATE, USER_STATE, dialog_main) # Direct message API diff --git a/src/bot.py b/src/bot.py index e7fc5f3..e486862 100644 --- a/src/bot.py +++ b/src/bot.py @@ -1,11 +1,10 @@ +from botbuilder.schema import ChannelAccount from botbuilder.core import ActivityHandler, TurnContext, ConversationState, UserState from botbuilder.dialogs import Dialog +from .dialogs.utils import Emoji from .dialogs.helpers import DialogHelper -from .nlu import NLU - -nlu = NLU() class Bot(ActivityHandler): @@ -16,6 +15,13 @@ def __init__(self, conversation_state: ConversationState, user_state: UserState, self.user_state = user_state self.dialog = dialog + async def on_members_added_activity(self, members_added: [ChannelAccount], turn_context: TurnContext): + + # Send an "Hello" to any new user connected to the bot + for member in members_added: + if member.id != turn_context.activity.recipient.id: + await turn_context.send_activity(f"Hello {Emoji.WAVING_HAND.value}") + async def on_turn(self, turn_context: TurnContext): await super().on_turn(turn_context) diff --git a/src/dialogs/__init__.py b/src/dialogs/__init__.py index 4c695f0..a928908 100644 --- a/src/dialogs/__init__.py +++ b/src/dialogs/__init__.py @@ -1,4 +1,5 @@ -from .room_reservation_dialog import RoomReservationDialog +from .booking_room_dialog import BookingRoomDialog +from .main_dialog import MainDialog -__all__ = ["RoomReservationDialog"] +__all__ = ["BookingRoomDialog", "MainDialog"] diff --git a/src/dialogs/booking_room_dialog.py b/src/dialogs/booking_room_dialog.py new file mode 100644 index 0000000..d24867e --- /dev/null +++ b/src/dialogs/booking_room_dialog.py @@ -0,0 +1,179 @@ + +from botbuilder.schema import ChannelAccount, CardAction, ActionTypes, SuggestedActions, Activity, ActivityTypes +from botbuilder.dialogs import ComponentDialog, WaterfallDialog, WaterfallStepContext, DialogTurnResult +from botbuilder.dialogs.prompts import TextPrompt, NumberPrompt, ChoicePrompt, ConfirmPrompt, AttachmentPrompt, PromptOptions, PromptValidatorContext +from botbuilder.dialogs.choices import Choice +from botbuilder.core import MessageFactory, UserState + +from src.nlu import Intent, NLU +from .utils import Emoji +from .helpers import NLUHelper +from .data_models import RoomReservation + + +class BookingRoomDialog(ComponentDialog): + + def __init__(self, nlu_recognizer: NLU, user_state: UserState): + super(BookingRoomDialog, self).__init__(BookingRoomDialog.__name__) + + # Load the NLU module + self._nlu_recognizer = nlu_recognizer + + # Load the RoomReservation class + self.room_reservation_accessor = user_state.create_property("RoomReservation") + + # Setup the waterfall dialog + self.add_dialog(WaterfallDialog("WFBookingDialog", [ + self.people_step, + self.duration_step, + self.breakfast_step, + self.summary_step, + ])) + + # Append the prompts and custom prompts + self.add_dialog(NumberPrompt("PeoplePrompt", BookingRoomDialog.people_prompt_validator)) + self.add_dialog(NumberPrompt("DurationPrompt", BookingRoomDialog.duration_prompt_validator)) + self.add_dialog(ConfirmPrompt("IsTakingBreakfastPrompt")) + + self.initial_dialog_id = "WFBookingDialog" + + @staticmethod + async def people_step(step_context: WaterfallStepContext) -> DialogTurnResult: + """Ask the user: how many people to make the reservation?""" + + # Retrieve the booking keywords + booking_keywords: dict = step_context.options + step_context.values['booking_keywords'] = booking_keywords + + # If the keyword 'people' exists and is filled, pass the question + if 'people' in booking_keywords and booking_keywords['people'] is not None: + return await step_context.next(booking_keywords['people']) + + # Give user suggestions (1 or 2 people). + # The user can still write a custom number of people [1, 4]. + options = PromptOptions( + prompt=Activity( + + type=ActivityTypes.message, + text="Would you like a single or a double room?", + + suggested_actions=SuggestedActions( + actions=[ + CardAction( + title="Single", + type=ActionTypes.im_back, + value="Single room (1 people)" + ), + CardAction( + title="Double", + type=ActionTypes.im_back, + value="Double room (2 peoples)" + ) + ] + ) + ), + retry_prompt=MessageFactory.text( + "Reservations can be made for one to four people only." + ) + ) + + # NumberPrompt - How many people ? + return await step_context.prompt( + "PeoplePrompt", + options + ) + + @staticmethod + async def duration_step(step_context: WaterfallStepContext) -> DialogTurnResult: + """Ask the user: how many night to reserve?""" + + # Save the number of people + step_context.values["people"] = step_context.result + + # Retrieve the keywords + booking_keywords: dict = step_context.values["booking_keywords"] + + # If the keyword 'duration' exists and is filled, pass the question + if 'duration' in booking_keywords and booking_keywords['duration'] is not None: + return await step_context.next(booking_keywords['duration']) + + # NumberPrompt - How many nights ? (duration) + return await step_context.prompt( + "DurationPrompt", + PromptOptions( + prompt=MessageFactory.text("How long do you want to stay?"), + retry_prompt=MessageFactory.text( + "It is only possible to book from 1 to 7 nights" + ), + ), + ) + + @staticmethod + async def breakfast_step(step_context: WaterfallStepContext) -> DialogTurnResult: + + # Save the number of nights + step_context.values["duration"] = step_context.result + + # Confirm people and duration + await step_context.context.send_activity( + MessageFactory.text( + f"Okay, so {step_context.values['people']} people for {step_context.values['duration']} nights" + ) + ) + + # ConfirmPrompt - Is taking breakfast ? + return await step_context.prompt( + "IsTakingBreakfastPrompt", + PromptOptions( + prompt=MessageFactory.text("Will you be having breakfast?") + ), + ) + + async def summary_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + + # Save if the user take the breakfast (bool) + step_context.values["breakfast"] = step_context.result + + # If the user said "Yes": + if step_context.result: + + # Confirm breakfast hour + await step_context.context.send_activity( + MessageFactory.text(f"Perfect, breakfast is from 6am to 10am") + ) + + # Save information to Reservation object + room_reservation = await self.room_reservation_accessor.get( + step_context.context, RoomReservation + ) + + room_reservation.people = step_context.values["people"] + room_reservation.duration = step_context.values["duration"] + room_reservation.breakfast = step_context.values["breakfast"] + + # End the dialog + await step_context.context.send_activity( + MessageFactory.text("Your booking has been made !") + ) + + return await step_context.end_dialog() + + @staticmethod + async def people_prompt_validator(prompt_context: PromptValidatorContext) -> bool: + """Validate the number of people entered by the user.""" + + # Restrict people between [1 and 4]. + return ( + prompt_context.recognized.succeeded + and 1 <= prompt_context.recognized.value <= 4 + ) + + @staticmethod + async def duration_prompt_validator(prompt_context: PromptValidatorContext) -> bool: + """Validate the number of nights entered by the user.""" + + # Restrict nights between [1 and 7]. + return ( + prompt_context.recognized.succeeded + and 1 <= prompt_context.recognized.value <= 7 + ) diff --git a/src/dialogs/helpers/__init__.py b/src/dialogs/helpers/__init__.py index cb98e27..62e19ca 100644 --- a/src/dialogs/helpers/__init__.py +++ b/src/dialogs/helpers/__init__.py @@ -1,4 +1,5 @@ from .dialogs_helper import DialogHelper +from .nlu_helper import NLUHelper -__all__ = ["DialogHelper"] +__all__ = ["DialogHelper", "NLUHelper"] diff --git a/src/dialogs/helpers/nlu_helper.py b/src/dialogs/helpers/nlu_helper.py index e69de29..0ece14a 100644 --- a/src/dialogs/helpers/nlu_helper.py +++ b/src/dialogs/helpers/nlu_helper.py @@ -0,0 +1,10 @@ + +from src.nlu import Intent, NLU + + +class NLUHelper: + + @staticmethod + async def execute_nlu_query(nlu_recognizer: NLU, message: str) -> (Intent, dict): + + return nlu_recognizer.get_intent(message) diff --git a/src/dialogs/main_dialog.py b/src/dialogs/main_dialog.py new file mode 100644 index 0000000..51706ac --- /dev/null +++ b/src/dialogs/main_dialog.py @@ -0,0 +1,98 @@ + +from botbuilder.schema import InputHints +from botbuilder.dialogs import ComponentDialog, WaterfallDialog, WaterfallStepContext, DialogTurnResult +from botbuilder.dialogs.prompts import TextPrompt, PromptOptions +from botbuilder.core import MessageFactory, UserState + +from src.nlu import Intent, NLU +from . import BookingRoomDialog +from .utils import Emoji +from .helpers import NLUHelper + + +class MainDialog(ComponentDialog): + + def __init__(self, nlu_recognizer: NLU, user_state: UserState, + booking_room_dialog: BookingRoomDialog): + + super(MainDialog, self).__init__(MainDialog.__name__) + + # Load the NLU module + self._nlu_recognizer = nlu_recognizer + + # Load the sub-dialogs + self._booking_dialog_id = booking_room_dialog.id + + # Setup the waterfall dialog + self.add_dialog(WaterfallDialog(WaterfallDialog.__name__, [ + self.intro_step, + self.act_step, + self.final_step + ])) + + # Append the prompts and custom dialogs, used in the waterfall + self.add_dialog(TextPrompt("ActPrompt")) + self.add_dialog(booking_room_dialog) + + self.initial_dialog_id = WaterfallDialog.__name__ + + @staticmethod + async def intro_step(step_context: WaterfallStepContext) -> DialogTurnResult: + """ + Intro step. Triggered upon any interaction from the user to this bot. + """ + + # Ask what to do + message = ( + str(step_context.options) + if step_context.options + else "What can I help you with today?" + ) + + # TextPromp - How can I help you ? + return await step_context.prompt( + "ActPrompt", + PromptOptions( + prompt=MessageFactory.text(message) + ), + ) + + async def act_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + """ + Act step. Take user response and infer its intention. + Dispatch to the desired sub-dialog + """ + + intent, keywords = await NLUHelper.execute_nlu_query( + self._nlu_recognizer, step_context.result + ) + + # Run the BookingRoomDialog, passing it keywords from nlu + if intent == Intent.BOOK_ROOM: + return await step_context.begin_dialog(self._booking_dialog_id, keywords) + + # If no intent was understood, return a didn't understand message + else: + didnt_understand_text = ( + "Sorry, I didn't get that. Please try asking in a different way" + ) + + await step_context.context.send_activity( + MessageFactory.text( + didnt_understand_text, didnt_understand_text, InputHints.ignoring_input + ) + ) + + return await step_context.next(None) + + async def final_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + """ + Final step. Triggered upon sub-dialog completion. Replace the current + dialog by the main dialog to start a new loop of conversation. + """ + + # Replace the current dialog back to main dialog + return await step_context.replace_dialog( + self.id, + "What else can I do for you?" + ) diff --git a/src/dialogs/room_reservation_dialog.py b/src/dialogs/room_reservation_dialog.py deleted file mode 100644 index bda5a33..0000000 --- a/src/dialogs/room_reservation_dialog.py +++ /dev/null @@ -1,109 +0,0 @@ - -from botbuilder.dialogs import ComponentDialog, WaterfallDialog, WaterfallStepContext, DialogTurnResult -from botbuilder.dialogs.prompts import TextPrompt, NumberPrompt, ChoicePrompt, ConfirmPrompt, AttachmentPrompt, PromptOptions, PromptValidatorContext -from botbuilder.dialogs.choices import Choice -from botbuilder.core import MessageFactory, UserState - -from .data_models import RoomReservation - - -class RoomReservationDialog(ComponentDialog): - - def __init__(self, user_state: UserState): - super(RoomReservationDialog, self).__init__(RoomReservationDialog.__name__) - - # Load the UserProfile class - self.room_reservation_accessor = user_state.create_property("RoomReservation") - - # Setup the waterfall dialog - self.add_dialog(WaterfallDialog(WaterfallDialog.__name__, [ - self.people_step, - self.nights_step, - self.breakfast_step, - self.summary_step, - ])) - - # Append the prompts and custom prompts - # self.add_dialog(TextPrompt(TextPrompt.__name__)) - self.add_dialog(NumberPrompt(NumberPrompt.__name__)) - self.add_dialog(ChoicePrompt(ChoicePrompt.__name__)) - self.add_dialog(ConfirmPrompt(ConfirmPrompt.__name__)) - - self.initial_dialog_id = WaterfallDialog.__name__ - - @staticmethod - async def people_step(step_context: WaterfallStepContext) -> DialogTurnResult: - - # ChoicePrompt - How many people ? - return await step_context.prompt( - ChoicePrompt.__name__, - PromptOptions( - prompt=MessageFactory.text("What size room will you need?"), - choices=[ - Choice("2 peoples"), - Choice("4 peoples"), - ], - ), - ) - - @staticmethod - async def nights_step(step_context: WaterfallStepContext) -> DialogTurnResult: - - # Save the number of people - step_context.values["people"] = step_context.result.value - - # Confirm the number of people - await step_context.context.send_activity( - MessageFactory.text(f"Okay, for {step_context.result.value}") - ) - - # NumberPrompt - How many nights ? (duration) - return await step_context.prompt( - NumberPrompt.__name__, - PromptOptions( - prompt=MessageFactory.text("How long do you want to stay?") - ), - ) - - @staticmethod - async def breakfast_step(step_context: WaterfallStepContext) -> DialogTurnResult: - - # Save the number of nights - step_context.values["duration"] = step_context.result - - # ConfirmPrompt - Is taking breakfast ? - return await step_context.prompt( - ConfirmPrompt.__name__, - PromptOptions( - prompt=MessageFactory.text("Will you be having breakfast?") - ), - ) - - async def summary_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - - # Save if the user take the breakfast (bool) - step_context.values["breakfast"] = step_context.result - - # If the user said "Yes": - if step_context.result: - - # Confirm breakfast hour - await step_context.context.send_activity( - MessageFactory.text(f"Perfect, breakfast is from 6am to 10am.") - ) - - # Save information to Reservation object - room_reservation = await self.room_reservation_accessor.get( - step_context.context, RoomReservation - ) - - room_reservation.people = step_context.values["people"] - room_reservation.duration = step_context.values["duration"] - room_reservation.breakfast = step_context.values["breakfast"] - - # End the dialog - await step_context.context.send_activity( - MessageFactory.text("Thanks. See you !") - ) - - return await step_context.end_dialog() diff --git a/src/dialogs/utils/__init__.py b/src/dialogs/utils/__init__.py new file mode 100644 index 0000000..d43a4a5 --- /dev/null +++ b/src/dialogs/utils/__init__.py @@ -0,0 +1,4 @@ + +from .emoji import Emoji + +__all__ = ['Emoji'] diff --git a/src/dialogs/utils/emoji.py b/src/dialogs/utils/emoji.py new file mode 100644 index 0000000..3d1dae2 --- /dev/null +++ b/src/dialogs/utils/emoji.py @@ -0,0 +1,7 @@ + +from enum import Enum + + +class Emoji(Enum): + + WAVING_HAND = "\U0001F44B" diff --git a/src/nlu/__init__.py b/src/nlu/__init__.py index d17c5f8..8e8fbe6 100644 --- a/src/nlu/__init__.py +++ b/src/nlu/__init__.py @@ -1,4 +1,5 @@ +from .intent import Intent from .nlu import NLU -__all__ = ["NLU"] +__all__ = ["NLU", "Intent"] diff --git a/src/nlu/classifying/classifier.py b/src/nlu/classifying/classifier.py index d187328..ab63a37 100644 --- a/src/nlu/classifying/classifier.py +++ b/src/nlu/classifying/classifier.py @@ -5,6 +5,7 @@ import torch from transformers import BertTokenizer, BertForSequenceClassification +from src.nlu import Intent from config import Config config = Config() @@ -68,7 +69,7 @@ def _load_model(self) -> BertForSequenceClassification: return model - def predict(self, dataset: BertTokenizer): + def predict(self, dataset: BertTokenizer) -> Intent: """Make a prediction and return the class.""" # Make the prediction, get an array of probabilities @@ -81,5 +82,5 @@ def predict(self, dataset: BertTokenizer): # Get the predicted class index _, predicted_index = torch.max(probabilities[0], dim=1) - # Return the class name - return self.labels[predicted_index.data[0].item()] + # Return the intent + return Intent(self.labels[predicted_index[0].item()]) diff --git a/src/nlu/intent.py b/src/nlu/intent.py new file mode 100644 index 0000000..adce4a1 --- /dev/null +++ b/src/nlu/intent.py @@ -0,0 +1,15 @@ + +from enum import Enum + + +class Intent(Enum): + + # Yes/No + YES = "smalltalk_confirmation_yes" + NO = "smalltalk_confirmation_no" + + # Small talks + GREETINGS = "smalltalk_greetings_hello" + + # Hotel long talks + BOOK_ROOM = "longtalk_make_reservation" diff --git a/src/nlu/nlu.py b/src/nlu/nlu.py index c59370a..c46a4e8 100644 --- a/src/nlu/nlu.py +++ b/src/nlu/nlu.py @@ -1,9 +1,8 @@ -from typing import Tuple - -from src.nlu.matching import Matcher -from src.nlu.preprocessing import Preprocessor, Tokenizer -from src.nlu.classifying import Classifier +from . import Intent +from .matching import Matcher +from .preprocessing import Preprocessor, Tokenizer +from .classifying import Classifier class NLU: @@ -18,7 +17,7 @@ def __init__(self): self.classifier = Classifier() self.matcher = Matcher() - def get_intent(self, message: str) -> Tuple[str, dict]: + def get_intent(self, message: str) -> (Intent, dict): """ Return the intention and the keywords of a given message. """ @@ -29,6 +28,6 @@ def get_intent(self, message: str) -> Tuple[str, dict]: # Get the intention intent = self.classifier.predict(dataset) - keywords = self.matcher.get_keywords(preprocessed_text, intent) + keywords = self.matcher.get_keywords(preprocessed_text, intent.value) return intent, keywords