- Notifications
You must be signed in to change notification settings - Fork13
Step Into the AI Era: Chatbots that know if you are angry - a workshop to build a chatbot using Rasa
License
Cheukting/rasa_workshop
Folders and files
Name | Name | Last commit message | Last commit date | |
---|---|---|---|---|
Repository files navigation
In this workshop, we will be usingRasa, an open source machine learning framework, to build a chatbot that will ask for an individual's contact details (compliant to GDPR) and for feedback for an event that they may have attended. Feedback will then be analyse for sentiment and reported in a basic web app.
- Part 1
- Part 2
- Part 3
This workshop uses Python >= 3.6. Make sure you have Python >= 3.6 available on your machine. You are recommended to use environment controls (conda or virtualenv) described below.
If you are using Mac or Linux, the best way to manage multiple Python environments is to usepyenv. If you don't have Python installed, you can skip installing Python directly on your machine. Instead,install pyenv andpyenv-virtualenv for creating different virtualenv environments with different versions of Pythons.
Another way to have Python on your machine (either Windows, Mac or Linux) is to downloadAnaconda orMiniconda, you will then create conda enviroments in the following step.
Open a terminal.
Clone this repo from Github:
git clone https://github.com/Cheukting/rasa_workshop.git
Enter the directory:
cd rasa_workshop
Create a newconda orvirtualenv environment.
conda create --name rasa_workshop python=x.x
wherex.x
is the python version (Rasa require python>=3.5)
or
pyenv virtualenv <version> rasa_workshop
where<version>
is the python version (Rasa require python>=3.5)
Notes:If virtualenv is too difficult to set up (e.g. using Windows) and already have Python >= 3.6 installed, you can usevenv instead
Activate the environment byconda activate rasa_workshop
orpyenv activate rasa_workshop
While you are in the new environment, install the requirements:
pip install -r requirements.txt
Notes:If you are using conda and have problems with pip install, you may try installing individual packages usingconda-forge
Creat a new directory (you could replacemy_chatbot
to any name you like):
mkdir my_chatbot
Go to the directory:
cd my_chatbot
Initiate a project:
rasa init --no-prompt
Rasa will create a list of files for you, but we mostly care about the following:
actions.py
: code for your custom actionsconfig.yml
: configuration of your NLU and Core modelsdata/nlu.md
: your NLU training datadata/stories.md
: your storiesdomain.yml
: your assistant’s domain
We will explain what they are and how to set them up in this workshop.
First we will need to train the NLU, which is a natural language processing tool for intent classification and entity extraction.
Opendata/nlu.md
with the text editor or IDE of your choice.
In the file, we see that some examples for different intents are already supplied. The intents are defined by lines starting with##
. Intents are a way to group messages with the same meaning, and example messages are provided below each intent. NLU's job will be to predict the correct intent for each new message your user sends your chatbot.
For our use case, since we will be doing sentiment analysis usingNatural Language Toolkit (NLTK), we can delete the sections formood_great
andmood_unhappy
.
Feel free to add more examples for the other intents: the more examples, the better the understanding of NLU and your chatbot.
Collecting user's data is one of the goals of our bot. To enable this, we need to add more intents for data capturing, such as:self_intro
,give_email
,give_tel
.
Here are some examples for the additional intents, please feel free to add more:
## intent:self_intro- I am [Mary](PERSON)- My name is [Anna](PERSON)## intent:give_email- my email is [joe@aol.com](email)- [123@123.co.uk](email)## intent:give_tel- my number is [01234567890](tel)- contact me at [07896234653](tel)
We can see that below the intents the examples have a slightly different structure than before. That is because each example contains an entity, which is a specific part of the text that needs to be identified. An entity has two terms and these function as a key-value pair:[entity](entity name)
. Often we would like the named entity to map to more than one entity and thus we can give multiple examples for each entity name, as we do above. We now have three entities:PERSON
,email
andtel
.
PERSON
is a entity provided by SpaCy. To help captureemail
andtel
, we will also useregex. To do so, put this innlu.md
as well:
## regex:email- [\w-]+@([\w-]+\.)+[\w-]+## regex:tel- (0)([0-9][\s]*){10}
If you are a regex expert, you can change it to a better expression. 😉
After that, we have to setup theNLP pipeline, which can be done by editingconfig.yml
. This configuration file defines the NLU and Core components that your model will use.
Inconfig.yml
change thesupervised_embeddings
topretrained_embeddings_spacy
so that we use the pretrained SpaCy embedding pipeline. You can find out more about NLU pipelineshere.
The following 2 commands download and set up the Spacy model that we will be using. In the terminal:
python -m spacy download en_core_web_md-2.0.0 --directpython -m spacy link en_core_web_md en
Then we tell rasa to train the NLU.
rasa train nlu
The trained model should be saved undermodels/
.
Notes:You can ignore all the future warnings for now as we will only use the current version in the workshop.
Now we can test the NLU model that we trained:
rasa shell nlu
After loading (may take a moment) you can type in messages and see the prediction that the NLU returns. If you are not happy with the result, you can go back and add more examples to thenlu.md
and then train the NLU again (rasa train nlu
). Repeat the training and testing until you are happy.
Congratulations, you have complete 1/3 of the workshop, feel free to take a 3 mins break
Now we will train our chatbots how to respond to messages. This is called dialogue management, and is handled by Rasa Core.
In this part, we will write the plan for the flow of the conversation. It will be written indata/stories.md
. The flow of the conversation will be broken into 3 parts:
greeting -> ask if user has attended event:
yes -> (go to part 2.a)
no -> (go to part 2.b)
a) ask for feedback -> ask if we can contact them
b) encourage them to go next year -> ask if we can contact them
yes -> (go to part 3.a)
no -> (go to part 3.b)
a) contact form and see you next year
b) see you next year
If you open and editdata/stories.md
, you can see that there are example stories already written. The story## say goodbye
enables the user to end the conversation at anytime. Keep## say goodbye
and delete the rest of the file. We will write our own stories for the conversation flow outlined above.
The skeleton for the 3 parts of our conversation flow looks like this:
## greetings* greet <something>> check ask experience## I have been to the event> check ask experience* affirm <something>> check ask contact## Not been to the event> check ask experience* deny <something>> check ask contact## get contact info> check ask contact* affirm <something>## do not contact me> check ask contact* deny <something>
We will fill in<something>
later. The lines with>
, e.g.> check ask experience
, are a checkpoints which link the different parts of the stories together. So instead of creating multiple dialogue stories where users answer the questions differently, we can use checkpoints to map different paths.
Line starting with*
are for when our chatbot recognises an intent. For example,* affirm
will trigger when the NLU predicts anaffirm
intent.
We also need to tell the chatbot what action to take and what to answer when it reaches certain points in the conversation. To do this, we define aDomain
for our chatbot, which is our chatbot's 'universe'. Your chatbot's domain specifies theintents
,entities
,slots
, andactions
your bot needs to know about. We will define these further below. The domain is recorded indomain.yml
. If you open updomain.yml
, you can see theDefaultDomain
. You can delete the contents of that file and then we'll create a domain for our chatbot.
The NLU model has defines theintents
, andentities
which need to be in the bots domain.
Remember the intents we defined innlu.md
? Let's put them indomain.yml
:
intents:- greet- goodbye- affirm- deny- self_intro- give_email- give_tel
Similarly, add the entities that we defined innlu.md
:
entities:- PERSON- email- tel
We will also be addingslots
andactions
to our bot's domain.Slots
store information that we want to keep track of in a conversation.Actions
are things your bot can do, including:respond to a user,make an external API call,query a database, orjust about anything!
We'll also be adding optional additional information to our bot's domain, includingforms
andtemplates
.
Slots enable us to store information about a conversation and the user having that conversation. For our chatbot, we would like to gather each user'sname
,email
andtel
number and userfeedback
:
slots: name: type: unfeaturized email: type: unfeaturized tel: type: unfeaturized feedback: type: unfeaturized
unfeaturized
means that this information does not affect the flow of the conversation.
To capture a user's contact information and feedback, we will use form actions. Let's define them like this for now:
forms: - experience_form - contact_form
There are two main kinds of actions for our chatbot. The first are Utterance Actions, which send a message to a user and start withutter_
. The second are Custom Actions, which run code you have written to enable your chatbot to perform specific custom actions. Our form actions are a type of custom action, and we will write the code for those actions further in the workshop.
For now, we will define ourutter
actions.
actions:- utter_greet- utter_happy- utter_goodbye- utter_thanks- utter_ask_contact- utter_ask_experience- utter_ask_name- utter_ask_email- utter_ask_tel- utter_ask_feedback- utter_submit- utter_wrong_email- utter_wrong_tel- utter_encourage
These are the different utterances of dialog for our chatbot. You will see them come into place as we complete our chatbot. You may come back and change theutter
actions later if you want.
Now we will add Utterance Templates, which are the messages our chatbot will send to the user. We need to define the response text for eachutter
action listed in our domain. If we have more than one response text for anutter
action, then one of them will be chosen at random for the chatbot's response. It's good design to have multiple responses so as to generate variety in your bot's dialogue.
For the utterance templates, the utterance can be used directly as an action.
templates: utter_greet: - text: "Hello! My name is Alex." utter_happy: - text: "Great!" - text: "Awesome!" utter_goodbye: - text: "Bye!" - text: "Have a nice day!" utter_thanks: - text: "Thank you for chatting, please feel free to talk to me again." utter_ask_contact: - text: "Do you want to be contacted regarding EuroPython next year?" utter_ask_experience: - text: "Have you been to EuroPython this year?" utter_ask_name: - text: "What's your name?" utter_ask_email: - text: "What's your email address?" utter_ask_tel: - text: "What's your contact number?" utter_ask_feedback: - text: "So, how was your experience in EuroPython?" utter_submit: - text: "You information collected will not be shared to 3rd party." utter_wrong_email: - text: "This doesn't look like an email..." utter_wrong_tel: - text: "This doesn't look like a phone number..." utter_encourage: - text: "It's a shame, we would like to meet you there next year."
Please feel free to change the response texts and add more response texts for eachutter
action.
For now, we are done withdomain.yml
; let's go back tostories.md
Now we know what's available in thedomain
, let's fill in the<something>
in the skeleton we had before:
## greetings* greet- utter_greet- utter_ask_experience> check ask experience## I have been to the event> check ask experience* affirm- utter_happy- experience_form- form{"name": "experience_form"}- form{"name": null}- utter_ask_contact> check ask contact## Not been to the event> check ask experience* deny- utter_encourage- utter_ask_contact> check ask contact## get contact info> check ask contact* affirm- utter_happy- contact_form- form{"name": "contact_form"}- form{"name": null}- utter_thanks## do not contact me> check ask contact* deny- utter_thanks
Notice that some of our actions start withform
, these are the form actions that we defined in our domain. For example,- form{"name": "experience_form"}
states to use the action formexperience_form
. After we are done, it will be reset tonull
to continue the conversation.
Let's set up our form actions now.
Now we come to the fun part! Our form actions are custom actions that we are using to collect the user's information. Before we do anything, first we need to add theFormPolicy
to the configuration. Go toconfig.yml
and underpolicies
add:
-name:FormPolicy
When a custom action is predicted in our dialogue, Core will call an endpoint we specify inendpoint.yml
. This endpoint should be a webserver that reacts to this call, runs the code for the custom action, and optionally returns information to modify the dialogue state.
To enable the action endpoint, go toendpoint.yml
and uncomment the following:
action_endpoint:url:"http://localhost:5055/webhook"
The custom action scripts we write will be hosted on a server setup by Rasa at port 5055.
Openactions.py
. From the default file, uncomment the following lines:
fromtypingimportAny,Text,Dict,Listfromrasa_sdkimportAction,Trackerfromrasa_sdk.executorimportCollectingDispatcher
These import an object that is used to communicate with the Rasa framework. On top of that, we also need:
fromrasa_sdk.formsimportFormAction
This additional import allows us to write custom form action classes which inherit fromFormAction
.
Let's define theexperience_form
, adding it below our imports inactions.py
:
classExperienceForm(FormAction):"""Form action to capture user experience"""defname(self):# type: () -> Text"""Unique identifier of the form"""return"experience_form"@staticmethoddefrequired_slots(tracker):# type: () -> List[Text]"""A list of required slots that the form has to fill this form collect the feedback of the user experience"""return ["feedback"]defsubmit(self,dispatcher,tracker,domain):# type: (CollectingDispatcher, Tracker, Dict[Text, Any]) -> List[Dict]"""Define what the form has to do after all required slots are filled. Generates sentiment analysis using the user's feedback"""return []defslot_mappings(self):# type: () -> Dict[Text: Union[Dict, List[Dict]]]"""A dictionary to map required slots to - an extracted entity - intent: value pairs - a whole message or a list of them, where a first match will be picked"""return {"feedback": [self.from_text()]}
This form will collect the text the user inputs in thefeedback
slot. When the form is triggered, the actionutter_ask_feedback
is activated and the user input after that will be captured. Have a look at the doc string of each methods and make sure you understand what each function does, we will use them again in the more complicatedcontact_form
.
Similarly, we definecontact_form
:
classContactForm(FormAction):"""Form action to capture contact details"""defname(self):# type: () -> Text"""Unique identifier of the form"""return"contact_form"@staticmethoddefrequired_slots(tracker):# type: () -> List[Text]"""A list of required slots that the form has to fill"""return ["name","email","tel"]defsubmit(self,dispatcher,tracker,domain):# type: (CollectingDispatcher, Tracker, Dict[Text, Any]) -> List[Dict]"""Define what the form has to do after all required slots are filled"""dispatcher.utter_template('utter_submit',tracker)return []defslot_mappings(self):# type: () -> Dict[Text: Union[Dict, List[Dict]]]"""A dictionary to map required slots to - an extracted entity - intent: value pairs - a whole message or a list of them, where a first match will be picked"""return {"name": [self.from_entity(entity="PERSON",intent="self_intro"),self.from_text()],"email": [self.from_entity(entity="email"),self.from_text()],"tel": [self.from_entity(entity="tel"),self.from_text()]}
This time the slot mapping is more complicated, usingfrom_entity
we can specify the slot to be fill with a certain recognised entity / intent instead of free text. However, we putfrom_text
in the list afterfrom_entity
as a fail safe catching the information if the user's input is not recognisable.
For theemail
andtel
the user input, we want to validate them. To do so, we add more methods to ourContactForm
class:
@staticmethoddefis_email(string:Text)->bool:"""Check if a string is valid email"""pattern=re.compile("[\w-]+@([\w-]+\.)+[\w-]+")returnpattern.match(string)@staticmethoddefis_tel(string:Text)->bool:"""Check if a string is valid email"""pattern_uk=re.compile("(0)([0-9][\s]*){10}")pattern_world=re.compile("^(00|\+)[\s]*[1-9]{1}([0-9][\s]*){9,16}$")returnpattern_uk.match(string)orpattern_world.match(string)defvalidate_email(self,value:Text,dispatcher:CollectingDispatcher,tracker:Tracker,domain:Dict[Text,Any], )->Optional[Text]:ifself.is_email(value):return {"email":value}else:dispatcher.utter_template('utter_wrong_email',tracker)# validation failed, set this slot to None, meaning the# user will be asked for the slot againreturn {"email":None}defvalidate_tel(self,value:Text,dispatcher:CollectingDispatcher,tracker:Tracker,domain:Dict[Text,Any], )->Optional[Text]:ifself.is_tel(value):return {"tel":value}else:dispatcher.utter_template('utter_wrong_tel',tracker)# validation failed, set this slot to None, meaning the# user will be asked for the slot againreturn {"tel":None}
Notice we have usedre
module, so we have to import it:
importre
Also, we have use one moretyping
:Optional
. We have to import it as well:
fromtypingimportAny,Text,Dict,List,Optional
Here we have defined 2 helper methods:is_email
andis_tel
which will use Regex to check if the input matches an email format and phone number format. We also have validate methods for each of them. If the format does not match what we expected, we will reset the slot toNone
and use anutter
action to ask again.
Now it's time to train and test our chatbot!! 🎉
To train the bot using the settings that we have set up, in the terminal run:
rasa train
When it is done, you can see that a new model is saved. Now let's try it out. First, make sure the server hosting the action script is up and running:
rasa run actions
Now the server is running, let's open an other terminal and then type:
rasa shell --endpoint endpoint.yml
(Note: you may need to activate the environment you created for the workshop.)
The command above will call Rasa to run the chatbot with the endpoint. Now you can talk to it!
In you have made changes to youractions.py
and want to start the server with the new script, you have to kill the server that is already running. Follow the following steps to kill the server:
- find the
PID
of the process:
sudo lsof -i tcp:5055
- kill the process:
kill -9 <PID>
fill in the<PID>
with thePID
you found in step 1.
You have complete 2/3 of the workshop! Yes, there's more. Feel free to take a 3 mins break
Here comes the fun part!! We will useNatural Language Toolkit (NLTK), a suite of libraries for natural language processing, to analyse the sentiment of thefeedback
so we know if the feedback is positive or negative.
Before we add code in the action script, let's add 2 more slots in ourdomain.yml
:
feedback_class:type:unfeaturizedfeedback_score:type:unfeaturized
This 2 slots will store the result of the analysis. Then head toactions.py
. First we have to import and download the resources in NLTK:
importnltknltk.download('vader_lexicon')fromnltk.sentiment.vaderimportSentimentIntensityAnalyzer
This is a built-in sentiment analyzer in NLTK and it's super easy to use. Then we add the following to thesubmit
method ofExperienceForm
:
sid=SentimentIntensityAnalyzer()all_slots=tracker.slotsforslot,valueinall_slots.items():ifslotinself.required_slots(tracker):res=sid.polarity_scores(value)score=res.pop('compound',None)classi,confidence=max(res.items(),key=lambdax:x[1])# classification of the feedback, could be pos, neg, or neuall_slots[slot+'_class']=classi# sentiment score of the feedback, range form -1 to 1all_slots[slot+'_score']=score
and return the new values of the slots:
return [SlotSet(slot,value)forslot,valueinall_slots.items()]
Here we use the analyzer to get the classification for the feedback, and its score, and store them in the new slots. To do so, we have to use a event in Rasa calledSlotSet
; let's import it at the beginning:
fromrasa_sdk.eventsimportSlotSet
Now you can restart the action server and test the chatbot again (remember to retrain it as we have changed thedomain.yml
). Make sure the chatbot works as before.
We cannot see the difference in the Rasa shell as the slots are not shown anywhere in the conversation. In the next part, we will generate a report using a web framework.
To display the information that we collected from the user, we have to generate a report. You can use any web framework of your choice but we'll use a lightweight framework calledCherryPy.
Since we are not teaching web development here, we will just tell you how to set it up with CherryPy. First open a new directory and go there. In the terminal:
mkdir reportcd report
create 3 files as follow:
- result.css
body {padding-left:15px;}
- result.html
<html><head><linkrel="stylesheet"href="result.css"></head><body><h1>{name} survey result</h1> {result}</body></html>
- result.py
importcherrypyimportosclassSurveyResult(object):@cherrypy.exposedefindex(self,name=None,result=None):returnopen("result.html").read().format(name=name,result=result)conf={'/result.css': {'tools.staticfile.on':True,'tools.staticfile.filename':os.path.abspath("./result.css"), } }if__name__=='__main__':cherrypy.quickstart(SurveyResult(),config=conf)
You may need to install cherrypy in your environment.
Then in the terminal:
python result.py
It will set up a web app running at port 8080. Just like with the action script server, we will leave it running and open a new terminal.
After setting up the report server, we have to add theAction
in the action script to send the request when the conversation is ended, but before that, we will need to add- action_show_result
underactions
indomain.yml
and at the end of the## get contact info
and## do not contact me
stories indata/stories.md
.
Inactions.py
add the following:
classActionShowResult(Action):"""open the html showing the result of the user survey"""defname(self):# type: () -> Textreturn"action_show_result"defrun(self,dispatcher,tracker,domain):# type: (CollectingDispatcher, Tracker, Dict[Text, Any]) -> List[Dict[Text, Any]]result=tracker.slotsname=result['name']ifnameisNone:name='Anonymous'else:name=name+"'s"http_result=""""""forkey,valueinresult.items():ifkey!='requested_slot':http_result+="""<p>{}: {}</p>""".format(key,value)# url of the server set up by result.pyurl='http://localhost:8080/?name={}&result={}'.format(name,http_result)webbrowser.open(url)return []
We'll need to importwebbrowser
:
importwebbrowser
This will gather the slots and send them with the request to the report server.
Now restart the action server and re-train rasa and test the chatbot.
So far everything should work fine if the user has been good. However, what if the user gives an unexpected answer and the NLU fails to determine what to do. Here we use a fallback action to prompt the user to try again. First we have to enableFallbackPolicy
, inconfig.yml
underpolicies
, add:
-name:"FallbackPolicy"nlu_threshold:0.4core_threshold:0.3fallback_action_name:"action_default_fallback"
action_default_fallback
is a default action in Rasa Core which sends theutter_default
template message to the user. So indomain.yml
, add- utter_default
underactions
andtemplates
:
utter_default:- text: "Sorry, I don't understand."- text: "I am not sure what you mean."
Now you can re-train and test the chatbot. Make sure you try to be a naughty user.
Congratulations! You have complicated the Rasa workshop... for now. Please feel free to integrate more functions to it, experiment and have fun.
For more things you can do with Rasa, please refer to theRasa documentation.
We are always looking for more content, so if you have a good idea, please feel free to contribute.