Repository: RasaHQ/rasa

Issue:
Exporting interactive training session failes if intent 'None' appears during training
**Rasa Core version**:
0.12.x
**Python version**: 
3.6
**Operating system** (windows, osx, ...):
ubuntu
**Issue**:
Exporting fails if e.g. during a FormAction you only provide an answer for a slot, without assigning the answer to an intent. In this case the story has an element like this:

> * shopping.add_items_to_wishlist
>     - form_shopping.search_items
>     - form{"name": "form_shopping.search_items"}
>     - slot{"requested_slot": "shopping.item"}
> * form: None

In that scenario, once you try to quit & export the execution crashes.

I think it should be either OK to not declare an intent or the user should be forced to do it. Otherwise it might crash after a long training session and you need to start all over.

Discussion:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 4240f66d13e..4f333080228 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -26,8 +26,8 @@ Removed
 
 Fixed
 -----
-- ``MappingPolicy`` now works correctly when used as part of a PolicyEnsemble
 - ``rasa run`` without ``--enable-api`` does not require a local model anymore
+- actions, intents, and utterances created in ``rasa interactive`` can no longer be empty
 
 
 [1.1.5] - 2019-07-10
@@ -68,6 +68,7 @@ Fixed
 - ``rasa interactive`` now looks for endpoints in ``endpoints.yml`` if no ``--endpoints`` arg is passed
 - custom files, e.g. custom components and channels, load correctly when using
   the command line interface
+- ``MappingPolicy`` now works correctly when used as part of a PolicyEnsemble
 
 
 [1.1.4] - 2019-06-18
diff --git a/rasa/core/training/interactive.py b/rasa/core/training/interactive.py
index 96da8a1644d..ea225da0108 100644
--- a/rasa/core/training/interactive.py
+++ b/rasa/core/training/interactive.py
@@ -334,20 +334,31 @@ def _selection_choices_from_intent_prediction(
 
 
 async def _request_free_text_intent(sender_id: Text, endpoint: EndpointConfig) -> Text:
-    question = questionary.text("Please type the intent name:")
+    question = questionary.text(
+        message="Please type the intent name:",
+        validate=io_utils.not_empty_validator("Please enter an intent name"),
+    )
     return await _ask_questions(question, sender_id, endpoint)
 
 
 async def _request_free_text_action(sender_id: Text, endpoint: EndpointConfig) -> Text:
-    question = questionary.text("Please type the action name:")
+    question = questionary.text(
+        message="Please type the action name:",
+        validate=io_utils.not_empty_validator("Please enter an action name"),
+    )
     return await _ask_questions(question, sender_id, endpoint)
 
 
 async def _request_free_text_utterance(
     sender_id: Text, endpoint: EndpointConfig, action: Text
 ) -> Text:
+
     question = questionary.text(
-        "Please type the message for your new utter_template '{}':".format(action)
+        message=(
+            "Please type the message for your new utterance "
+            "template '{}':".format(action)
+        ),
+        validate=io_utils.not_empty_validator("Please enter a template message"),
     )
     return await _ask_questions(question, sender_id, endpoint)
 
@@ -661,7 +672,7 @@ def _request_export_info() -> Tuple[Text, Text, Text]:
             message="Export stories to (if file exists, this "
             "will append the stories)",
             default=PATHS["stories"],
-            validate=io_utils.questionary_file_path_validator(
+            validate=io_utils.file_type_validator(
                 [".md"],
                 "Please provide a valid export path for the stories, e.g. 'stories.md'.",
             ),
@@ -670,7 +681,7 @@ def _request_export_info() -> Tuple[Text, Text, Text]:
             message="Export NLU data to (if file exists, this will "
             "merge learned data with previous training examples)",
             default=PATHS["nlu"],
-            validate=io_utils.questionary_file_path_validator(
+            validate=io_utils.file_type_validator(
                 [".md"],
                 "Please provide a valid export path for the NLU data, e.g. 'nlu.md'.",
             ),
@@ -679,7 +690,7 @@ def _request_export_info() -> Tuple[Text, Text, Text]:
             message="Export domain file to (if file exists, this "
             "will be overwritten)",
             default=PATHS["domain"],
-            validate=io_utils.questionary_file_path_validator(
+            validate=io_utils.file_type_validator(
                 [".yml", ".yaml"],
                 "Please provide a valid export path for the domain file, e.g. 'domain.yml'.",
             ),
diff --git a/rasa/utils/io.py b/rasa/utils/io.py
index b504b7d71ed..7d757d490a8 100644
--- a/rasa/utils/io.py
+++ b/rasa/utils/io.py
@@ -7,7 +7,7 @@ import tempfile
 import warnings
 import zipfile
 from asyncio import AbstractEventLoop
-from typing import Text, Any, Dict, Union, List, Type
+from typing import Text, Any, Dict, Union, List, Type, Callable
 import ruamel.yaml as yaml
 from io import BytesIO as IOReader
 
@@ -257,23 +257,45 @@ def create_directory_for_file(file_path: Text) -> None:
             raise
 
 
-def questionary_file_path_validator(
+def file_type_validator(
     valid_file_types: List[Text], error_message: Text
 ) -> Type["Validator"]:
     """Creates a `Validator` class which can be used with `questionary` to validate
        file paths.
     """
 
+    def is_valid(path: Text) -> bool:
+        return path is not None and any(
+            [path.endswith(file_type) for file_type in valid_file_types]
+        )
+
+    return create_validator(is_valid, error_message)
+
+
+def not_empty_validator(error_message: Text) -> Type["Validator"]:
+    """Creates a `Validator` class which can be used with `questionary` to validate
+    that the user entered something other than whitespace.
+    """
+
+    def is_valid(input: Text) -> bool:
+        return input is not None and input.strip() != ""
+
+    return create_validator(is_valid, error_message)
+
+
+def create_validator(
+    function: Callable[[Text], bool], error_message: Text
+) -> Type["Validator"]:
+    """Helper method to create `Validator` classes from callable functions. Should be
+    removed when questionary supports `Validator` objects."""
+
     from prompt_toolkit.validation import Validator, ValidationError
     from prompt_toolkit.document import Document
 
-    class ExportPathValidator(Validator):
-        def validate(self, document: Document) -> None:
-            path = document.text
-            is_valid = path is not None and any(
-                [path.endswith(file_type) for file_type in valid_file_types]
-            )
+    class FunctionValidator(Validator):
+        def validate(document: Document) -> None:
+            is_valid = function(document.text)
             if not is_valid:
                 raise ValidationError(message=error_message)
 
-    return ExportPathValidator
+    return FunctionValidator


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index ebeadc06487..8cd3866efda 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -2,6 +2,8 @@ import logging
 
 import pytest
 from aioresponses import aioresponses
+from prompt_toolkit.document import Document
+from prompt_toolkit.validation import ValidationError
 
 from rasa.constants import DOMAIN_SCHEMA_FILE, CONFIG_SCHEMA_FILE
 from rasa.utils.validation import validate_yaml_schema, InvalidYamlFileError
@@ -127,14 +129,10 @@ def test_read_file_with_not_existing_path():
 
 @pytest.mark.parametrize("actual_path", ["", "file.md", "file"])
 def test_file_path_validator_with_invalid_paths(actual_path):
-    from prompt_toolkit.validation import ValidationError
-    from prompt_toolkit.document import Document
 
     test_error_message = actual_path
 
-    validator = rasa.utils.io.questionary_file_path_validator(
-        [".yml"], test_error_message
-    )()
+    validator = rasa.utils.io.file_type_validator([".yml"], test_error_message)
 
     document = Document(actual_path)
     with pytest.raises(ValidationError) as e:
@@ -145,12 +143,51 @@ def test_file_path_validator_with_invalid_paths(actual_path):
 
 @pytest.mark.parametrize("actual_path", ["domain.yml", "lala.yaml"])
 def test_file_path_validator_with_valid_paths(actual_path):
-    from prompt_toolkit.document import Document
 
-    validator = rasa.utils.io.questionary_file_path_validator(
-        [".yml", ".yaml"], "error message"
-    )()
+    validator = rasa.utils.io.file_type_validator([".yml", ".yaml"], "error message")
 
     document = Document(actual_path)
     # If the path is valid there shouldn't be an exception
     assert validator.validate(document) is None
+
+
+@pytest.mark.parametrize("user_input", ["", "   ", "\t", "\n"])
+def test_non_empty_text_validator_with_empty_input(user_input):
+
+    test_error_message = "enter something"
+
+    validator = rasa.utils.io.not_empty_validator(test_error_message)
+
+    document = Document(user_input)
+    with pytest.raises(ValidationError) as e:
+        validator.validate(document)
+
+    assert e.value.message == test_error_message
+
+
+@pytest.mark.parametrize("user_input", ["utter_greet", "greet", "Hi there!"])
+def test_non_empty_text_validator_with_valid_input(user_input):
+
+    validator = rasa.utils.io.not_empty_validator("error message")
+
+    document = Document(user_input)
+    # If there is input there shouldn't be an exception
+    assert validator.validate(document) is None
+
+
+def test_create_validator_from_callable():
+    def is_valid(user_input) -> None:
+        return user_input == "this passes"
+
+    error_message = "try again"
+
+    validator = rasa.utils.io.create_validator(is_valid, error_message)
+
+    document = Document("this passes")
+    assert validator.validate(document) is None
+
+    document = Document("this doesn't")
+    with pytest.raises(ValidationError) as e:
+        validator.validate(document)
+
+    assert e.value.message == error_message