diff --git a/changelog/7068.improvement.md b/changelog/7068.improvement.md new file mode 100644 index 000000000000..f3a918715272 --- /dev/null +++ b/changelog/7068.improvement.md @@ -0,0 +1,16 @@ +Slot mappings for [Forms](forms.mdx) in the domain are now optional. If you do not +provide any slot mappings as part of the domain, you need to provide +[custom slot mappings](forms.mdx#custom-slot-mappings) through a custom action. +A form without slot mappings is specified as follows: + +```rasa-yaml +forms: + my_form: + # no mappings +``` + +The action for [forms](forms.mdx) can now be overridden by defining a custom action +with the same name as the form. This can be used to keep using the deprecated +Rasa Open Source `FormAction` which is implemented within the Rasa SDK. Note that it is +**not** recommended to override the form action for anything else than using the +deprecated Rasa SDK `FormAction`. diff --git a/docs/docs/default-actions.mdx b/docs/docs/default-actions.mdx index 70ea1da4f536..f062e26dc952 100644 --- a/docs/docs/default-actions.mdx +++ b/docs/docs/default-actions.mdx @@ -148,3 +148,12 @@ their message. This action undoes the last user-bot interaction. It can be triggered by the user by sending a "/back" message to the assistant if the [RulePolicy](./policies.mdx#rule-policy) is configured. | +## Form Action + +By default Rasa Open Source uses `FormAction` for processing any +[form logic](forms.mdx). You can override this default action with a custom action by +adding a custom action with the form's name to the domain. +Overriding the default action for forms should **only** be used during the process of +migrating from Rasa Open Source 1 to 2. In this case you can override the default +action to instruct Rasa Open Source to use the deprecated `FormAction` which is part of +the Rasa SDK. diff --git a/docs/docs/migration-guide.mdx b/docs/docs/migration-guide.mdx index f800317be063..3ab49ab105a1 100644 --- a/docs/docs/migration-guide.mdx +++ b/docs/docs/migration-guide.mdx @@ -378,6 +378,23 @@ class RestaurantFormValidator(FormValidationAction): return {"cuisine": None} ``` +You can also migrate forms from Rasa SDK to Rasa Open Source 2 iteratively. You can for +example migrate one form to the Rasa Open Source 2 implementation while continue using +the deprecated Rasa SDK implementation for another form. To continue to use +the deprecated Rasa SDK `FormAction`s, add a custom action with the name of your form to your domain. Note that you should complete the migration as soon as possible as the deprecated `FormAction` +will be removed from the Rasa SDK in Rasa Open Source 3. + +```yaml-rasa title="domain.yml" +actions: +# Adding a custom action for a form will +# instruct Rasa Open Source to use the +# deprecated Rasa SDK implementation of forms. +- my_form + +forms: + my_form: +``` + See the [forms](./forms.mdx) documentation for more details. ### Response Selectors diff --git a/rasa/core/actions/action.py b/rasa/core/actions/action.py index 8a5eccde7e70..53793313d0f0 100644 --- a/rasa/core/actions/action.py +++ b/rasa/core/actions/action.py @@ -124,17 +124,7 @@ def action_for_name( if action_name not in domain.action_names: domain.raise_action_not_found_exception(action_name) - should_use_form_action = ( - action_name in domain.form_names and domain.slot_mapping_for_form(action_name) - ) - - return action_from_name( - action_name, - action_endpoint, - domain.user_actions_and_forms, - should_use_form_action, - domain.retrieval_intents, - ) + return action_from_name(action_name, domain, action_endpoint) def is_retrieval_action(action_name: Text, retrieval_intents: List[Text]) -> bool: @@ -158,30 +148,40 @@ def is_retrieval_action(action_name: Text, retrieval_intents: List[Text]) -> boo def action_from_name( - name: Text, - action_endpoint: Optional[EndpointConfig], - user_actions: List[Text], - should_use_form_action: bool = False, - retrieval_intents: Optional[List[Text]] = None, + name: Text, domain: Domain, action_endpoint: Optional[EndpointConfig] ) -> "Action": - """Return an action instance for the name.""" + """Retrieves an action by its name. + Args: + name: The name of the action. + domain: The current model domain. + action_endpoint: The endpoint to execute custom actions. + + Returns: + The instantiated action. + """ defaults = {a.name(): a for a in default_actions(action_endpoint)} - if name in defaults and name not in user_actions: + if name in defaults and name not in domain.user_actions_and_forms: return defaults[name] - elif name.startswith(UTTER_PREFIX) and is_retrieval_action( - name, retrieval_intents or [] + + if name.startswith(UTTER_PREFIX) and is_retrieval_action( + name, domain.retrieval_intents ): return ActionRetrieveResponse(name) - elif name.startswith(UTTER_PREFIX): + + if name.startswith(UTTER_PREFIX): return ActionUtterTemplate(name) - elif should_use_form_action: + + is_form = name in domain.form_names + # Users can override the form by defining an action with the same name as the form + user_overrode_form_action = is_form and name in domain.user_actions + if is_form and not user_overrode_form_action: from rasa.core.actions.forms import FormAction return FormAction(name, action_endpoint) - else: - return RemoteAction(name, action_endpoint) + + return RemoteAction(name, action_endpoint) def create_bot_utterance(message: Dict[Text, Any]) -> BotUttered: diff --git a/rasa/core/actions/forms.py b/rasa/core/actions/forms.py index fce75169e7d4..98ddf39eefb4 100644 --- a/rasa/core/actions/forms.py +++ b/rasa/core/actions/forms.py @@ -554,9 +554,7 @@ async def _ask_for_slot( logger.debug(f"Request next slot '{slot_name}'") action_to_ask_for_next_slot = action.action_from_name( - self._name_of_utterance(domain, slot_name), - self.action_endpoint, - domain.user_actions, + self._name_of_utterance(domain, slot_name), domain, self.action_endpoint ) events_to_ask_for_next_slot = await action_to_ask_for_next_slot.run( output_channel, nlg, tracker, domain diff --git a/rasa/core/actions/two_stage_fallback.py b/rasa/core/actions/two_stage_fallback.py index 529df249e3d8..51c8067b5bc6 100644 --- a/rasa/core/actions/two_stage_fallback.py +++ b/rasa/core/actions/two_stage_fallback.py @@ -55,9 +55,7 @@ async def _ask_affirm( domain: Domain, ) -> List[Event]: affirm_action = action.action_from_name( - ACTION_DEFAULT_ASK_AFFIRMATION_NAME, - self._action_endpoint, - domain.user_actions, + ACTION_DEFAULT_ASK_AFFIRMATION_NAME, domain, self._action_endpoint ) return await affirm_action.run(output_channel, nlg, tracker, domain) @@ -70,7 +68,7 @@ async def _ask_rephrase( domain: Domain, ) -> List[Event]: rephrase = action.action_from_name( - ACTION_DEFAULT_ASK_REPHRASE_NAME, self._action_endpoint, domain.user_actions + ACTION_DEFAULT_ASK_REPHRASE_NAME, domain, self._action_endpoint ) return await rephrase.run(output_channel, nlg, tracker, domain) @@ -138,7 +136,7 @@ async def _give_up( domain: Domain, ) -> List[Event]: fallback = action.action_from_name( - ACTION_DEFAULT_FALLBACK_NAME, self._action_endpoint, domain.user_actions + ACTION_DEFAULT_FALLBACK_NAME, domain, self._action_endpoint ) return await fallback.run(output_channel, nlg, tracker, domain) diff --git a/rasa/core/training/interactive.py b/rasa/core/training/interactive.py index 8fa98ac5eab6..a025062cdd4e 100644 --- a/rasa/core/training/interactive.py +++ b/rasa/core/training/interactive.py @@ -937,7 +937,7 @@ def _write_domain_to_file( slots=[], templates=templates, action_names=collected_actions, - forms=[], + forms={}, ) old_domain.merge(new_domain).persist_clean(domain_path) diff --git a/rasa/shared/core/domain.py b/rasa/shared/core/domain.py index 1ffaa91144c6..0072a1e4f975 100644 --- a/rasa/shared/core/domain.py +++ b/rasa/shared/core/domain.py @@ -104,7 +104,7 @@ class Domain: @classmethod def empty(cls) -> "Domain": - return cls([], [], [], {}, [], []) + return cls([], [], [], {}, [], {}) @classmethod def load(cls, paths: Union[List[Union[Path, Text]], Text, Path]) -> "Domain": @@ -175,7 +175,7 @@ def from_dict(cls, data: Dict) -> "Domain": slots, utter_templates, data.get(KEY_ACTIONS, []), - data.get(KEY_FORMS, []), + data.get(KEY_FORMS, {}), data.get(KEY_E2E_ACTIONS, []), session_config=session_config, **additional_arguments, @@ -507,13 +507,14 @@ def __init__( self.intent_properties = self.collect_intent_properties( intents, self.entities, self.roles, self.groups ) - self.overriden_default_intents = self._collect_overridden_default_intents( + self.overridden_default_intents = self._collect_overridden_default_intents( intents ) - self.forms: Dict[Text, Any] = {} - self.form_names: List[Text] = [] - self._initialize_forms(forms) + self.form_names, self.forms, overridden_form_actions = self._initialize_forms( + forms + ) + action_names += overridden_form_actions self.slots = slots self.templates = templates @@ -528,7 +529,11 @@ def __init__( # includes all actions (custom, utterance, default actions and forms) self.action_names = ( self._combine_user_with_default_actions(self.user_actions) - + self.form_names + + [ + form_name + for form_name in self.form_names + if form_name not in self._custom_actions + ] + self.action_texts ) @@ -569,34 +574,55 @@ def _collect_overridden_default_intents( } return sorted(intent_names & set(rasa.shared.core.constants.DEFAULT_INTENTS)) - def _initialize_forms(self, forms: Union[Dict[Text, Any], List[Text]]) -> None: - """Initialize the domain's `self.form` and `self.form_names` attributes. + @staticmethod + def _initialize_forms( + forms: Union[Dict[Text, Any], List[Text]] + ) -> Tuple[List[Text], Dict[Text, Any], List[Text]]: + """Retrieves the initial values for the Domain's form fields. Args: forms: Form names (if forms are a list) or a form dictionary. Forms provided in dictionary format have the form names as keys, and either empty dictionaries as values, or objects containing `SlotMapping`s. + + Returns: + The form names, a mapping of form names and slot mappings, and custom + actions. + Returning custom actions for each forms means that Rasa Open Source should + not use the default `FormAction` for the forms, but rather a custom action + for it. This can e.g. be used to run the deprecated Rasa Open Source 1 + `FormAction` which is implemented in the Rasa SDK. """ - if not forms: - # empty dict or empty list - return - elif isinstance(forms, dict): + if isinstance(forms, dict): # dict with slot mappings - self.forms = forms - self.form_names = list(forms.keys()) - elif isinstance(forms, list) and isinstance(forms[0], str): - # list of form names - self.forms = {form_name: {} for form_name in forms} - self.form_names = forms - else: + return list(forms.keys()), forms, [] + + if isinstance(forms, list) and (not forms or isinstance(forms[0], str)): + # list of form names (Rasa Open Source 1 format) rasa.shared.utils.io.raise_warning( - f"The `forms` section in the domain needs to contain a dictionary. " - f"Instead found an object of type '{type(forms)}'.", + "The `forms` section in the domain used the old Rasa Open Source 1 " + "list format to define forms. Rasa Open Source will be configured to " + "use the deprecated `FormAction` within the Rasa SDK. If you want to " + "use the new Rasa Open Source 2 `FormAction` adapt your `forms` " + "section as described in the documentation. Support for the " + "deprecated `FormAction` in the Rasa SDK will be removed in Rasa Open " + "Source 3.0.", docs=rasa.shared.constants.DOCS_URL_FORMS, + category=FutureWarning, ) + return forms, {form_name: {} for form_name in forms}, forms + + rasa.shared.utils.io.raise_warning( + f"The `forms` section in the domain needs to contain a dictionary. " + f"Instead found an object of type '{type(forms)}'.", + docs=rasa.shared.constants.DOCS_URL_FORMS, + ) + + return [], {}, [] def __hash__(self) -> int: + """Returns a unique hash for the domain.""" self_as_dict = self.as_dict() self_as_dict[ KEY_INTENTS @@ -1032,7 +1058,7 @@ def _transform_intents_for_file(self) -> List[Union[Text, Dict[Text, Any]]]: for intent_name, intent_props in intent_properties.items(): if ( intent_name in rasa.shared.core.constants.DEFAULT_INTENTS - and intent_name not in self.overriden_default_intents + and intent_name not in self.overridden_default_intents ): # Default intents should be not dumped with the domain continue diff --git a/rasa/shared/importers/importer.py b/rasa/shared/importers/importer.py index fef01a2b9e90..ba23887f98c9 100644 --- a/rasa/shared/importers/importer.py +++ b/rasa/shared/importers/importer.py @@ -382,7 +382,7 @@ def _get_domain_with_retrieval_intents( RetrievalModelsDataImporter._construct_retrieval_action_names( retrieval_intents ), - [], + {}, ) async def get_stories( @@ -459,7 +459,7 @@ async def _get_domain_with_e2e_actions(self) -> Domain: additional_e2e_action_names = list(additional_e2e_action_names) return Domain( - [], [], [], {}, action_names=additional_e2e_action_names, forms=[] + [], [], [], {}, action_names=additional_e2e_action_names, forms={} ) async def get_stories( diff --git a/tests/core/actions/test_forms.py b/tests/core/actions/test_forms.py index 57c33eee3a06..352094aa2c0b 100644 --- a/tests/core/actions/test_forms.py +++ b/tests/core/actions/test_forms.py @@ -1101,12 +1101,9 @@ async def test_ask_for_slot( monkeypatch.setattr(action, action.action_from_name.__name__, action_from_name) form = FormAction("my_form", endpoint_config) + domain = Domain.from_dict(domain) await form._ask_for_slot( - Domain.from_dict(domain), - None, - None, - slot_name, - DialogueStateTracker.from_events("dasd", []), + domain, None, None, slot_name, DialogueStateTracker.from_events("dasd", []) ) - action_from_name.assert_called_once_with(expected_action, endpoint_config, ANY) + action_from_name.assert_called_once_with(expected_action, domain, endpoint_config) diff --git a/tests/core/test_actions.py b/tests/core/test_actions.py index e4a8ca3b19fb..79a4a9493ee2 100644 --- a/tests/core/test_actions.py +++ b/tests/core/test_actions.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Text import pytest from aioresponses import aioresponses @@ -97,7 +97,7 @@ def test_domain_action_instantiation(): slots=[], templates={}, action_names=["my_module.ActionTest", "utter_test", "utter_chitchat"], - forms=[], + forms={}, ) instantiated_actions = [ @@ -693,7 +693,17 @@ async def test_action_default_ask_rephrase( ] -def test_get_form_action(): +@pytest.mark.parametrize( + "slot_mapping", + [ + """ + my_slot: + - type:from_text + """, + "", + ], +) +def test_get_form_action(slot_mapping: Text): form_action_name = "my_business_logic" domain = Domain.from_yaml( f""" @@ -701,8 +711,7 @@ def test_get_form_action(): - my_action forms: {form_action_name}: - my_slot: - - type: from_text + {slot_mapping} """ ) @@ -710,14 +719,31 @@ def test_get_form_action(): assert isinstance(actual, FormAction) -def test_get_form_action_without_slot_mapping(): +def test_get_form_action_with_rasa_open_source_1_forms(): + form_action_name = "my_business_logic" + with pytest.warns(FutureWarning): + domain = Domain.from_yaml( + f""" + actions: + - my_action + forms: + - {form_action_name} + """ + ) + + actual = action.action_for_name(form_action_name, domain, None) + assert isinstance(actual, RemoteAction) + + +def test_overridden_form_action(): form_action_name = "my_business_logic" domain = Domain.from_yaml( f""" actions: - my_action - forms: - {form_action_name} + forms: + {form_action_name}: """ ) diff --git a/tests/core/test_featurizer.py b/tests/core/test_featurizer.py index f7452ff0d756..dd3024ac184c 100644 --- a/tests/core/test_featurizer.py +++ b/tests/core/test_featurizer.py @@ -118,7 +118,7 @@ def test_single_state_featurizer_creates_encoded_all_actions(): entities=[], slots=[], templates={}, - forms=[], + forms={}, action_names=["a", "b", "c", "d"], ) f = SingleStateFeaturizer() diff --git a/tests/shared/core/test_domain.py b/tests/shared/core/test_domain.py index 32e6a83cc1f2..630542fa44e4 100644 --- a/tests/shared/core/test_domain.py +++ b/tests/shared/core/test_domain.py @@ -38,7 +38,7 @@ ) -def test_slots_states_before_user_utterance(default_domain): +def test_slots_states_before_user_utterance(default_domain: Domain): featurizer = MaxHistoryTrackerFeaturizer() tracker = DialogueStateTracker.from_events( "bla", @@ -55,7 +55,7 @@ def test_slots_states_before_user_utterance(default_domain): assert trackers_as_states == expected_states -async def test_create_train_data_no_history(default_domain): +async def test_create_train_data_no_history(default_domain: Domain): featurizer = MaxHistoryTrackerFeaturizer(max_history=1) training_trackers = await training.load_data( DEFAULT_STORIES_FILE, default_domain, augmentation_factor=0 @@ -87,7 +87,7 @@ async def test_create_train_data_no_history(default_domain): ] -async def test_create_train_data_with_history(default_domain): +async def test_create_train_data_with_history(default_domain: Domain): featurizer = MaxHistoryTrackerFeaturizer(max_history=4) training_trackers = await training.load_data( DEFAULT_STORIES_FILE, default_domain, augmentation_factor=0 @@ -132,8 +132,6 @@ def check_for_too_many_entities_and_remove_them(state: State) -> State: async def test_create_train_data_unfeaturized_entities(): - import copy - domain_file = "data/test_domains/default_unfeaturized_entities.yml" stories_file = "data/test_stories/stories_unfeaturized_entities.md" domain = Domain.load(domain_file) @@ -718,7 +716,7 @@ def test_check_domain_sanity_on_invalid_domain(): slots=[], templates={}, action_names=["random_name", "random_name"], - forms=[], + forms={}, ) with pytest.raises(InvalidDomain): @@ -728,7 +726,7 @@ def test_check_domain_sanity_on_invalid_domain(): slots=[TextSlot("random_name"), TextSlot("random_name")], templates={}, action_names=[], - forms=[], + forms={}, ) with pytest.raises(InvalidDomain): @@ -738,7 +736,7 @@ def test_check_domain_sanity_on_invalid_domain(): slots=[], templates={}, action_names=[], - forms=[], + forms={}, ) with pytest.raises(InvalidDomain): @@ -872,7 +870,7 @@ def test_clean_domain_for_file(): assert cleaned == expected -def test_add_knowledge_base_slots(default_domain): +def test_add_knowledge_base_slots(default_domain: Domain): # don't modify default domain as it is used in other tests test_domain = copy.deepcopy(default_domain) @@ -999,7 +997,7 @@ def test_domain_deepcopy(): # equalities assert new_domain.intent_properties == domain.intent_properties - assert new_domain.overriden_default_intents == domain.overriden_default_intents + assert new_domain.overridden_default_intents == domain.overridden_default_intents assert new_domain.entities == domain.entities assert new_domain.forms == domain.forms assert new_domain.form_names == domain.form_names @@ -1014,7 +1012,9 @@ def test_domain_deepcopy(): # not the same objects assert new_domain is not domain assert new_domain.intent_properties is not domain.intent_properties - assert new_domain.overriden_default_intents is not domain.overriden_default_intents + assert ( + new_domain.overridden_default_intents is not domain.overridden_default_intents + ) assert new_domain.entities is not domain.entities assert new_domain.forms is not domain.forms assert new_domain.form_names is not domain.form_names