Custom Forms
Note: This is material for Rasa Open Source 3.x. If you're interested in the content for Rasa Open Source 2.x, please see the archived version of this lesson here.
Video
You may have been planning for a form using a diagram like below.
But a form also needs to be robust against unhappy paths. We may be dealing with a user who is asking for clarification while they're providing the assistant with information.
Whenever this happens, you'd like the assistant to answer the prompt, and afterwards fall back to the form.
So let's discuss how to configure Rasa forms to deal with this.
Code
Github
We will share code snippets below. But we also have a Github repository with the entire Rasa project. If you'd prefer to explore the code that way, simply go to the repository here.
That said, let's begin by adapting our rules.yml
file.
rules:- rule: Interruption in Pizza Form condition: # Condition that form is active. - active_loop: simple_pizza_form steps: # This unhappy path handles the case of an intent `bot_challenge`. - intent: bot_challenge - action: utter_iamabot # Return to form after handling the `bot_challenge` intent - action: simple_pizza_form - active_loop: simple_pizza_form
The idea here is that we can add rules that are able to handle an "interrupt"
that occurs from within a form. In this case, once the utter_iamabot
response
is sent, control is given back to the form loop.
Stopping a Form
Normally, a form would shut down automatically once a user has given all required slots. But what if the user was to stop a form prematurely? In that case we need to think about ways to deactive the loop of a form.
Let's try to implement this behavior, following the diagram below.
To implement this behavior, we need to resort to stories.
stories:- story: User interrupts the form and doesn't want to continue steps: - intent: greet - action: utter_greet - intent: buy_pizza - action: simple_pizza_form - active_loop: simple_pizza_form - intent: stop - action: utter_ask_continue - or: - intent: stop - intent: affirm - action: action_deactivate_loop - active_loop: null
There are a few things to take note of here.
- You may wonder why we need to resort to stories instead of rules here. The reason is that rules only allow for short patterns. You're only allowed to have one intent that's used in a rule, which won't work for the situation that we have here. This means that in production the
RulePolicy
won't apply here and instead theMemoizationPolicy
might take over. - Once the
stop
intent is detected in a form we ask the user if they are sure. They're able to use either thestop
oraffirm
intent to declare what they want to happen. If it's clear the user wants to stop, we trigger theaction_deactivate_loop
action and terminate the loop.
If you're trying to build these flows, we highly recommend using the rasa interactive
command as a way to get started.
Advanced Form Customisation
To show how flexible Rasa forms are, we're going to take our form a step further. What we're about to show is an advanced use-case though, so don't worry if you feel the need to re-read parts of the tutorial.
The idea is to make a form that uses buttons to ask questions that can also change it's behavior depending on the information that it has received. In particular, we'll start the form by asking if the user is interested in a vegetarian pizza and we'll show different options later in the form depending on the answer.
We'll make a new fancy_pizza_form
that facilitates this. That means we'll first need to make
changes to the domain.yml
file.
entities:- pizza_size- pizza_typeforms: simple_pizza_form: required_slots: - pizza_size - pizza_type fancy_pizza_form: required_slots: - vegetarian - pizza_size - pizza_typeslots: pizza_size: type: text influence_conversation: true mappings: - type: from_entity entity: pizza_size conditions: - active_loop: pizza_form requested_slot: pizza_size pizza_type: type: text influence_conversation: true mappings: - type: from_entity entity: pizza_type vegetarian: type: bool influence_conversation: true mappings: - type: from_intent value: true intent: affirm - type: from_intent value: false intent: deny
actions:- validate_fancy_pizza_form- action_ask_pizza_type- action_ask_vegetarian
With our domain.yml
set up, we now get to make a custom actions
that will ask the user for information on our behalf.
from typing import Text, List, Any, Dict
from rasa_sdk import Tracker, FormValidationAction, Actionfrom rasa_sdk.events import EventTypefrom rasa_sdk.executor import CollectingDispatcherfrom rasa_sdk.types import DomainDict
ALLOWED_PIZZA_SIZES = [ "small", "medium", "large", "extra-large", "extra large", "s", "m", "l", "xl"]ALLOWED_PIZZA_TYPES = [ "mozzarella", "fungi", "veggie", "pepperoni", "hawaii"]VEGETARIAN_PIZZAS = [ "mozzarella", "fungi", "veggie"]MEAT_PIZZAS = [ "pepperoni", "hawaii"]
# Technically, this Action could have also been implemented as a simple # response in the domain.yml file.class AskForVegetarianAction(Action): def name(self) -> Text: return "action_ask_vegetarian"
def run( self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: Dict ) -> List[EventType]: dispatcher.utter_message( text="Would you like to order a vegetarian pizza?", buttons=[ {"title": "yes", "payload": "/affirm"}, {"title": "no", "payload": "/deny"}, ], ) return []
# This Action needs to run Python code to generate the response for the user# so this *must* be implemented as an action. It cannot be handled by a static# response in the domain.yml fileclass AskForPizzaTypeAction(Action): def name(self) -> Text: return "action_ask_pizza_type"
def run( self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: Dict ) -> List[EventType]: if tracker.get_slot("vegetarian"): dispatcher.utter_message( text=f"What kind of pizza do you want?", buttons=[{"title": p, "payload": p} for p in VEGETARIAN_PIZZAS], ) else: dispatcher.utter_message( text=f"What kind of pizza do you want?", buttons=[{"title": p, "payload": p} for p in MEAT_PIZZAS], ) return []
These custom actions act as responses that can also handle logic on our behalf. In particular,
note how the AskForPizzaTypeAction
sends a different message that's based on the vegetarian
slot.
It's also good to observe what the payload of all the buttons contain. The idea
is the when a user hits a button with the payload "fungi"
that Rasa will accept
it as if the user typed "fungi" in the dialogue box. If the payload starts with
a backslash, like in "/affirm"
, it'll be interpreted as an intent instead.
Validation
Our fancy_pizza_form
still needs a validator. It's defined below.
class ValidateFancyPizzaForm(FormValidationAction): def name(self) -> Text: return "validate_fancy_pizza_form"
def validate_vegetarian( self, slot_value: Any, dispatcher: CollectingDispatcher, tracker: Tracker, domain: DomainDict, ) -> Dict[Text, Any]: """Validate `pizza_size` value.""" if tracker.get_intent_of_latest_message() == "affirm": dispatcher.utter_message( text="I'll remember you prefer vegetarian." ) return {"vegetarian": True} if tracker.get_intent_of_latest_message() == "deny": dispatcher.utter_message( text="I'll remember that you don't want a vegetarian pizza." ) return {"vegetarian": False} dispatcher.utter_message(text="I didn't get that.") return {"vegetarian": None}
def validate_pizza_size( self, slot_value: Any, dispatcher: CollectingDispatcher, tracker: Tracker, domain: DomainDict, ) -> Dict[Text, Any]: """Validate `pizza_size` value."""
if slot_value not in ALLOWED_PIZZA_SIZES: dispatcher.utter_message(text=f"We only accept pizza sizes: s/m/l/xl.") return {"pizza_size": None} dispatcher.utter_message(text=f"OK! You want to have a {slot_value} pizza.") return {"pizza_size": slot_value}
def validate_pizza_type( self, slot_value: Any, dispatcher: CollectingDispatcher, tracker: Tracker, domain: DomainDict, ) -> Dict[Text, Any]: """Validate `pizza_type` value."""
if slot_value not in ALLOWED_PIZZA_TYPES: dispatcher.utter_message( text=f"I don't recognize that pizza. We serve {'/'.join(ALLOWED_PIZZA_TYPES)}." ) return {"pizza_type": None} if not slot_value: dispatcher.utter_message( text=f"I don't recognize that pizza. We serve {'/'.join(ALLOWED_PIZZA_TYPES)}." ) return {"pizza_type": None} dispatcher.utter_message(text=f"OK! You want to have a {slot_value} pizza.") return {"pizza_type": slot_value}
There's a few things to observe here.
- Note that the
validate_vegetarian
method doesn't really validate the slot. Rather, it sets it by using intents! - We still have the
validate_pizza_size
andvalidate_pizza_type
methods to validate the information that comes in. - When a user pressed a button, the information will still need to pass a validation step. We want to keep the validators in place because the user may also choose to type in the dialogue bar instead of pressing a button.
Try it out!
If you're interested in experimenting with this we recommend that you
use rasa interactive
. That way you're also able to inspect the slot
values as the conversation moves forward.
You're also free to copy the code from our repository here.
Links
Exercises
Try to answer the following questions to test your knowledge.
- Can a validator for slot A also invalidate the data in slot B?
- Why do you sometimes need to define a story for specific behaviors with Rasa forms?