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


Appendix

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 the MemoizationPolicy 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 the stop or affirm intent to declare what they want to happen. If it's clear the user wants to stop, we trigger the action_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_type
forms:
simple_pizza_form:
required_slots:
- pizza_size
- pizza_type
fancy_pizza_form:
required_slots:
- vegetarian
- pizza_size
- pizza_type
slots:
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, Action
from rasa_sdk.events import EventType
from rasa_sdk.executor import CollectingDispatcher
from 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 file
class 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 and validate_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?

2016-2022 © Rasa.