From 618fc43df552e6f3ae3853b61bd066e79793a451 Mon Sep 17 00:00:00 2001 From: Anca Lita <27920906+ancalita@users.noreply.github.com> Date: Fri, 22 Sep 2023 10:02:40 +0100 Subject: [PATCH 1/5] Add support for predicate evaluation as a slot validation method (#12802) * implement changes * update default flow * implement expected behaviour after invalid values given twice * fix CI unit tests after adding new default slot * revert default slot, implement review suggestion * some fixes * amend if check to step id comparison * adapt to list format of checks, add new default action for running predicates * make changes for re-ask continuous loop until valid outcome * fix tests * refactor slot rejection to a dataclass, rename default action * refactor action_run_slot_rejections * add tests for default action * address review comments --- rasa/core/actions/action.py | 4 + .../actions/action_run_slot_rejections.py | 131 ++++ rasa/core/policies/flow_policy.py | 34 +- .../patterns/collect_information.py | 15 +- .../patterns/default_flows_for_patterns.yml | 2 +- .../stack/frames/dialogue_stack_frame.py | 8 +- rasa/shared/core/constants.py | 2 + rasa/shared/core/domain.py | 11 +- rasa/shared/core/flows/flow.py | 45 +- tests/cdu/stack/test_dialogue_stack.py | 2 + .../test_action_run_slot_rejections.py | 561 ++++++++++++++++++ .../featurizers/test_tracker_featurizer.py | 14 +- tests/core/test_actions.py | 10 +- .../story_reader/test_yaml_story_reader.py | 2 +- tests/shared/importers/test_rasa.py | 1 + 15 files changed, 811 insertions(+), 31 deletions(-) create mode 100644 rasa/core/actions/action_run_slot_rejections.py create mode 100644 tests/core/actions/test_action_run_slot_rejections.py diff --git a/rasa/core/actions/action.py b/rasa/core/actions/action.py index 65f5e51cd444..28258cfab877 100644 --- a/rasa/core/actions/action.py +++ b/rasa/core/actions/action.py @@ -100,6 +100,9 @@ def default_actions(action_endpoint: Optional[EndpointConfig] = None) -> List["A from rasa.dialogue_understanding.patterns.correction import ActionCorrectFlowSlot from rasa.dialogue_understanding.patterns.cancel import ActionCancelFlow from rasa.dialogue_understanding.patterns.clarify import ActionClarifyFlows + from rasa.core.actions.action_run_slot_rejections import ( + ActionRunSlotRejections, + ) return [ ActionListen(), @@ -118,6 +121,7 @@ def default_actions(action_endpoint: Optional[EndpointConfig] = None) -> List["A ActionCancelFlow(), ActionCorrectFlowSlot(), ActionClarifyFlows(), + ActionRunSlotRejections(), ] diff --git a/rasa/core/actions/action_run_slot_rejections.py b/rasa/core/actions/action_run_slot_rejections.py new file mode 100644 index 000000000000..ad55d4bf6069 --- /dev/null +++ b/rasa/core/actions/action_run_slot_rejections.py @@ -0,0 +1,131 @@ +from typing import Any, Dict, List, Optional, TYPE_CHECKING, Text + +import structlog +from jinja2 import Template +from pypred import Predicate + +from rasa.core.actions.action import Action, create_bot_utterance +from rasa.dialogue_understanding.patterns.collect_information import ( + CollectInformationPatternFlowStackFrame, +) +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.shared.core.constants import ACTION_RUN_SLOT_REJECTIONS_NAME +from rasa.shared.core.events import Event, SlotSet + +if TYPE_CHECKING: + from rasa.core.nlg import NaturalLanguageGenerator + from rasa.core.channels.channel import OutputChannel + from rasa.shared.core.domain import Domain + from rasa.shared.core.trackers import DialogueStateTracker + +structlogger = structlog.get_logger() + + +class ActionRunSlotRejections(Action): + """Action which evaluates the predicate checks under rejections.""" + + def name(self) -> Text: + """Return the name of the action.""" + return ACTION_RUN_SLOT_REJECTIONS_NAME + + async def run( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + metadata: Optional[Dict[Text, Any]] = None, + ) -> List[Event]: + """Run the predicate checks.""" + events: List[Event] = [] + violation = False + utterance = None + internal_error = False + + dialogue_stack = DialogueStack.from_tracker(tracker) + top_frame = dialogue_stack.top() + if not isinstance(top_frame, CollectInformationPatternFlowStackFrame): + return [] + + if not top_frame.rejections: + return [] + + slot_name = top_frame.collect_information + slot_instance = tracker.slots.get(slot_name) + if slot_instance and not slot_instance.has_been_set: + # this is the first time the assistant asks for the slot value, + # therefore we skip the predicate validation because the slot + # value has not been provided + structlogger.debug( + "first.collect.slot.not.set", + slot_name=slot_name, + slot_value=slot_instance.value, + ) + return [] + + slot_value = tracker.get_slot(slot_name) + + current_context = dialogue_stack.current_context() + current_context[slot_name] = slot_value + + structlogger.debug("run.predicate.context", context=current_context) + document = current_context.copy() + + for rejection in top_frame.rejections: + condition = rejection.if_ + utterance = rejection.utter + + try: + rendered_template = Template(condition).render(current_context) + predicate = Predicate(rendered_template) + violation = predicate.evaluate(document) + structlogger.debug( + "run.predicate.result", + predicate=predicate.description(), + violation=violation, + ) + except (TypeError, Exception) as e: + structlogger.error( + "run.predicate.error", + predicate=condition, + document=document, + error=str(e), + ) + violation = True + internal_error = True + + if violation: + break + + if not violation: + return [] + + # reset slot value that was initially filled with an invalid value + events.append(SlotSet(top_frame.collect_information, None)) + + if internal_error: + utterance = "utter_internal_error_rasa" + + if not isinstance(utterance, str): + structlogger.error( + "run.rejection.missing.utter", + utterance=utterance, + ) + return events + + message = await nlg.generate( + utterance, + tracker, + output_channel.name(), + ) + + if message is None: + structlogger.error( + "run.rejection.failed.finding.utter", + utterance=utterance, + ) + else: + message["utter_action"] = utterance + events.append(create_bot_utterance(message)) + + return events diff --git a/rasa/core/policies/flow_policy.py b/rasa/core/policies/flow_policy.py index 273bc97f8b96..ede3003a87d9 100644 --- a/rasa/core/policies/flow_policy.py +++ b/rasa/core/policies/flow_policy.py @@ -53,6 +53,7 @@ IfFlowLink, EntryPromptFlowStep, CollectInformationScope, + SlotRejection, StepThatCanStartAFlow, UserMessageStep, LinkFlowStep, @@ -171,6 +172,7 @@ def predict_action_probabilities( domain: The model's domain. rule_only_data: Slots and loops which are specific to rules and hence should be ignored by this policy. + flows: The flows to use. **kwargs: Depending on the specified `needs` section and the resulting graph structure the policy can use different input to make predictions. @@ -208,7 +210,7 @@ def _create_prediction_result( domain: The model's domain. score: The score of the predicted action. - Resturns: + Returns: The prediction result where the score is used for one hot encoding. """ result = self._default_predictions(domain) @@ -242,8 +244,9 @@ def __init__( """Initializes the `FlowExecutor`. Args: - dialogue_stack_frame: State of the flow. + dialogue_stack: State of the flow. all_flows: All flows. + domain: The domain. """ self.dialogue_stack = dialogue_stack self.all_flows = all_flows @@ -258,6 +261,7 @@ def from_tracker( Args: tracker: The tracker to create the `FlowExecutor` from. flows: The flows to use. + domain: The domain to use. Returns: The created `FlowExecutor`. @@ -270,7 +274,6 @@ def find_startable_flow(self, tracker: DialogueStateTracker) -> Optional[Flow]: Args: tracker: The tracker containing the conversation history up to now. - flows: The flows to use. Returns: The predicted action and the events to run. @@ -296,7 +299,7 @@ def is_condition_satisfied( ) -> bool: """Evaluate a predicate condition.""" - # attach context to the predicate evaluation to allow coditions using it + # attach context to the predicate evaluation to allow conditions using it context = {"context": DialogueStack.from_tracker(tracker).current_context()} document: Dict[str, Any] = context.copy() for slot in self.domain.slots: @@ -371,7 +374,7 @@ def render_template_variables(text: str, context: Dict[Text, Any]) -> str: return Template(text).render(context) def _slot_for_collect_information(self, collect_information: Text) -> Slot: - """Find the slot for a collect information.""" + """Find the slot for the collect information step.""" for slot in self.domain.slots: if slot.name == collect_information: return slot @@ -415,7 +418,6 @@ def advance_flows(self, tracker: DialogueStateTracker) -> ActionPrediction: Args: tracker: The tracker to get the next action for. - domain: The domain to get the next action for. Returns: The predicted action and the events to run. @@ -456,7 +458,6 @@ def _select_next_action( Args: tracker: The tracker to get the next action for. - domain: The domain to get the next action for. Returns: The next action to execute, the events that should be applied to the @@ -552,11 +553,14 @@ def _run_step( """ if isinstance(step, CollectInformationFlowStep): structlogger.debug("flow.step.run.collect_information") - self.trigger_pattern_ask_collect_information(step.collect_information) + self.trigger_pattern_ask_collect_information( + step.collect_information, step.rejections + ) - # reset the slot if its already filled and the collect infomation shouldn't + # reset the slot if its already filled and the collect information shouldn't # be skipped slot = tracker.slots.get(step.collect_information, None) + if slot and slot.has_been_set and step.ask_before_filling: events = [SlotSet(step.collect_information, slot.initial_value)] else: @@ -567,8 +571,10 @@ def _run_step( elif isinstance(step, ActionFlowStep): if not step.action: raise FlowException(f"Action not specified for step {step}") + context = {"context": self.dialogue_stack.current_context()} action_name = self.render_template_variables(step.action, context) + if action_name in self.domain.action_names_or_texts: structlogger.debug("flow.step.run.action", context=context) return PauseFlowReturnPrediction(ActionPrediction(action_name, 1.0)) @@ -676,10 +682,16 @@ def trigger_pattern_completed(self, current_frame: DialogueStackFrame) -> None: ) ) - def trigger_pattern_ask_collect_information(self, collect_information: str) -> None: + def trigger_pattern_ask_collect_information( + self, + collect_information: str, + rejections: List[SlotRejection], + ) -> None: + """Trigger the pattern to ask for a slot value.""" self.dialogue_stack.push( CollectInformationPatternFlowStackFrame( - collect_information=collect_information + collect_information=collect_information, + rejections=rejections, ) ) diff --git a/rasa/dialogue_understanding/patterns/collect_information.py b/rasa/dialogue_understanding/patterns/collect_information.py index 86442c6edc8b..f56c3bfe2c3d 100644 --- a/rasa/dialogue_understanding/patterns/collect_information.py +++ b/rasa/dialogue_understanding/patterns/collect_information.py @@ -1,10 +1,11 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStackFrame from rasa.shared.constants import RASA_DEFAULT_FLOW_PATTERN_PREFIX from rasa.dialogue_understanding.stack.frames import PatternFlowStackFrame +from rasa.shared.core.flows.flow import SlotRejection FLOW_PATTERN_COLLECT_INFORMATION = ( RASA_DEFAULT_FLOW_PATTERN_PREFIX + "ask_collect_information" @@ -20,6 +21,11 @@ class CollectInformationPatternFlowStackFrame(PatternFlowStackFrame): collect_information: str = "" """The information that should be collected from the user. this corresponds to the slot that will be filled.""" + rejections: Optional[List[SlotRejection]] = None + """The predicate check that should be applied to the collected information. + If a predicate check fails, its `utter` action indicated under rejections + will be executed. + """ @classmethod def type(cls) -> str: @@ -36,10 +42,17 @@ def from_dict(data: Dict[str, Any]) -> CollectInformationPatternFlowStackFrame: Returns: The created `DialogueStackFrame`. """ + rejections = data.get("rejections") + if rejections is not None: + rejections = [ + SlotRejection.from_dict(rejection) for rejection in rejections + ] + return CollectInformationPatternFlowStackFrame( data["frame_id"], step_id=data["step_id"], collect_information=data["collect_information"], + rejections=rejections, ) def context_as_dict( diff --git a/rasa/dialogue_understanding/patterns/default_flows_for_patterns.yml b/rasa/dialogue_understanding/patterns/default_flows_for_patterns.yml index 83f094fc9b9f..93373798b4d3 100644 --- a/rasa/dialogue_understanding/patterns/default_flows_for_patterns.yml +++ b/rasa/dialogue_understanding/patterns/default_flows_for_patterns.yml @@ -110,7 +110,7 @@ flows: description: flow used to fill a slot steps: - id: "start" - action: action_extract_slots + action: action_run_slot_rejections next: "validate" - id: "validate" action: validate_{{context.collect_information}} diff --git a/rasa/dialogue_understanding/stack/frames/dialogue_stack_frame.py b/rasa/dialogue_understanding/stack/frames/dialogue_stack_frame.py index 077bf7c2115f..46e55bc02f29 100644 --- a/rasa/dialogue_understanding/stack/frames/dialogue_stack_frame.py +++ b/rasa/dialogue_understanding/stack/frames/dialogue_stack_frame.py @@ -52,8 +52,14 @@ def as_dict(self) -> Dict[str, Any]: def custom_asdict_factory(fields: List[Tuple[str, Any]]) -> Dict[str, Any]: """Converts enum values to their value.""" + + def rename_internal(field_name: str) -> str: + return field_name[:-1] if field_name.endswith("_") else field_name + return { - field: value.value if isinstance(value, Enum) else value + rename_internal(field): value.value + if isinstance(value, Enum) + else value for field, value in fields } diff --git a/rasa/shared/core/constants.py b/rasa/shared/core/constants.py index f548b3ddd22d..ed2d31b4cee5 100644 --- a/rasa/shared/core/constants.py +++ b/rasa/shared/core/constants.py @@ -40,6 +40,7 @@ ACTION_CANCEL_FLOW = "action_cancel_flow" ACTION_CLARIFY_FLOWS = "action_clarify_flows" ACTION_CORRECT_FLOW_SLOT = "action_correct_flow_slot" +ACTION_RUN_SLOT_REJECTIONS_NAME = "action_run_slot_rejections" DEFAULT_ACTION_NAMES = [ @@ -60,6 +61,7 @@ ACTION_CANCEL_FLOW, ACTION_CORRECT_FLOW_SLOT, ACTION_CLARIFY_FLOWS, + ACTION_RUN_SLOT_REJECTIONS_NAME, ] ACTION_SHOULD_SEND_DOMAIN = "send_domain" diff --git a/rasa/shared/core/domain.py b/rasa/shared/core/domain.py index d523cbb5f053..c105926a09b2 100644 --- a/rasa/shared/core/domain.py +++ b/rasa/shared/core/domain.py @@ -54,7 +54,13 @@ import rasa.shared.utils.common import rasa.shared.core.slot_mappings from rasa.shared.core.events import SlotSet, UserUttered -from rasa.shared.core.slots import Slot, CategoricalSlot, TextSlot, AnySlot, ListSlot +from rasa.shared.core.slots import ( + Slot, + CategoricalSlot, + TextSlot, + AnySlot, + ListSlot, +) from rasa.shared.utils.validation import KEY_TRAINING_DATA_FORMAT_VERSION from rasa.shared.nlu.constants import ( ENTITY_ATTRIBUTE_TYPE, @@ -969,9 +975,6 @@ def _add_categorical_slot_default_value(self) -> None: def _add_flow_slots(self) -> None: """Adds the slots needed for the conversation flows. - Add a slot called `flow_step_slot` to the list of slots. The value of - this slot will hold the name of the id of the next step in the flow. - Add a slot called `dialogue_stack_slot` to the list of slots. The value of this slot will be a call stack of the flow ids. """ diff --git a/rasa/shared/core/flows/flow.py b/rasa/shared/core/flows/flow.py index 09b09b9faf17..e5b470ada4f3 100644 --- a/rasa/shared/core/flows/flow.py +++ b/rasa/shared/core/flows/flow.py @@ -981,16 +981,54 @@ def from_str(label: Optional[Text]) -> "CollectInformationScope": raise NotImplementedError +@dataclass +class SlotRejection: + """A slot rejection.""" + + if_: str + """The condition that should be checked.""" + utter: str + """The utterance that should be executed if the condition is met.""" + + @staticmethod + def from_dict(rejection_config: Dict[Text, Any]) -> SlotRejection: + """Used to read slot rejections from parsed YAML. + + Args: + rejection_config: The parsed YAML as a dictionary. + + Returns: + The parsed slot rejection. + """ + return SlotRejection( + if_=rejection_config["if"], + utter=rejection_config["utter"], + ) + + def as_dict(self) -> Dict[Text, Any]: + """Returns the slot rejection as a dictionary. + + Returns: + The slot rejection as a dictionary. + """ + return { + "if": self.if_, + "utter": self.utter, + } + + @dataclass class CollectInformationFlowStep(FlowStep): """Represents the configuration of a collect information flow step.""" collect_information: Text """The collect information of the flow step.""" + rejections: List[SlotRejection] + """how the slot value is validated using predicate evaluation.""" ask_before_filling: bool = False """Whether to always ask the question even if the slot is already filled.""" scope: CollectInformationScope = CollectInformationScope.FLOW - """how the question is scoped, determins when to reset its value.""" + """how the question is scoped, determines when to reset its value.""" @classmethod def from_json(cls, flow_step_config: Dict[Text, Any]) -> CollectInformationFlowStep: @@ -1007,6 +1045,10 @@ def from_json(cls, flow_step_config: Dict[Text, Any]) -> CollectInformationFlowS collect_information=flow_step_config.get("collect_information", ""), ask_before_filling=flow_step_config.get("ask_before_filling", False), scope=CollectInformationScope.from_str(flow_step_config.get("scope")), + rejections=[ + SlotRejection.from_dict(rejection) + for rejection in flow_step_config.get("rejections", []) + ], **base.__dict__, ) @@ -1020,6 +1062,7 @@ def as_json(self) -> Dict[Text, Any]: dump["collect_information"] = self.collect_information dump["ask_before_filling"] = self.ask_before_filling dump["scope"] = self.scope.value + dump["rejections"] = [rejection.as_dict() for rejection in self.rejections] return dump diff --git a/tests/cdu/stack/test_dialogue_stack.py b/tests/cdu/stack/test_dialogue_stack.py index c5f36a3d0521..96c3fa2dcbad 100644 --- a/tests/cdu/stack/test_dialogue_stack.py +++ b/tests/cdu/stack/test_dialogue_stack.py @@ -66,6 +66,7 @@ def test_dialogue_stack_as_dict(): "frame_id": "some-other-id", "step_id": "__start__", "flow_id": "pattern_ask_collect_information", + "rejections": None, }, ] @@ -201,6 +202,7 @@ def test_get_current_context(): "step_id": "first_step", "type": "flow", "collect_information": "foo", + "rejections": None, } diff --git a/tests/core/actions/test_action_run_slot_rejections.py b/tests/core/actions/test_action_run_slot_rejections.py new file mode 100644 index 000000000000..d22169acc65c --- /dev/null +++ b/tests/core/actions/test_action_run_slot_rejections.py @@ -0,0 +1,561 @@ +import uuid +from typing import Optional, Text + +import pytest +from pytest import CaptureFixture + +from rasa.core.actions.action_run_slot_rejections import ActionRunSlotRejections +from rasa.core.channels import OutputChannel +from rasa.core.nlg import TemplatedNaturalLanguageGenerator +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import BotUttered, SlotSet, UserUttered +from rasa.shared.core.slots import AnySlot, FloatSlot, TextSlot +from rasa.shared.core.trackers import DialogueStateTracker + + +@pytest.fixture +def rejection_test_nlg() -> TemplatedNaturalLanguageGenerator: + return TemplatedNaturalLanguageGenerator( + { + "utter_ask_recurrent_payment_type": [ + {"text": "What type of recurrent payment do you want to setup?"} + ], + "utter_invalid_recurrent_payment_type": [ + {"text": "Sorry, you requested an invalid recurrent payment type."} + ], + "utter_internal_error_rasa": [{"text": "Sorry, something went wrong."}], + "utter_ask_payment_amount": [{"text": "What amount do you want to pay?"}], + "utter_payment_too_high": [ + {"text": "Sorry, the amount is above the maximum £1,000 allowed."} + ], + "utter_payment_negative": [ + {"text": "Sorry, the amount cannot be negative."} + ], + } + ) + + +@pytest.fixture +def rejection_test_domain() -> Domain: + return Domain.from_yaml( + """ + slots: + recurrent_payment_type: + type: text + mappings: [] + payment_recipient: + type: text + mappings: [] + payment_amount: + type: float + mappings: [] + responses: + utter_ask_recurrent_payment_type: + - text: "What type of recurrent payment do you want to setup?" + utter_invalid_recurrent_payment_type: + - text: "Sorry, you requested an invalid recurrent payment type." + utter_internal_error_rasa: + - text: "Sorry, something went wrong." + utter_ask_payment_amount: + - text: "What amount do you want to pay?" + utter_payment_too_high: + - text: "Sorry, the amount is above the maximum £1,000 allowed." + utter_payment_negative: + - text: "Sorry, the amount cannot be negative." + """ + ) + + +async def test_action_run_slot_rejections_top_frame_not_collect_information( + default_channel: OutputChannel, + rejection_test_nlg: TemplatedNaturalLanguageGenerator, + rejection_test_domain: Domain, +) -> None: + dialogue_stack = [ + { + "frame_id": "4YL3KDBR", + "flow_id": "setup_recurrent_payment", + "step_id": "ask_payment_type", + "frame_type": "regular", + "type": "flow", + }, + ] + tracker = DialogueStateTracker.from_events( + sender_id=uuid.uuid4().hex, + evts=[ + UserUttered("i want to setup a new recurrent payment."), + SlotSet("dialogue_stack", dialogue_stack), + ], + slots=[ + TextSlot("recurrent_payment_type", mappings=[]), + AnySlot("dialogue_stack", mappings=[]), + ], + ) + + action_run_slot_rejections = ActionRunSlotRejections() + + events = await action_run_slot_rejections.run( + output_channel=default_channel, + nlg=rejection_test_nlg, + tracker=tracker, + domain=rejection_test_domain, + ) + + assert events == [] + + +async def test_action_run_slot_rejections_top_frame_none_rejections( + default_channel: OutputChannel, + rejection_test_nlg: TemplatedNaturalLanguageGenerator, + rejection_test_domain: Domain, +) -> None: + dialogue_stack = [ + { + "frame_id": "4YL3KDBR", + "flow_id": "setup_recurrent_payment", + "step_id": "ask_payment_recipient", + "frame_type": "regular", + "type": "flow", + }, + { + "frame_id": "6Z7PSTRM", + "flow_id": "pattern_ask_collect_information", + "step_id": "start", + "collect_information": "payment_recipient", + "rejections": [], + "type": "pattern_collect_information", + }, + ] + + tracker = DialogueStateTracker.from_events( + sender_id=uuid.uuid4().hex, + evts=[ + UserUttered("I want to make a payment."), + SlotSet("dialogue_stack", dialogue_stack), + ], + slots=[ + TextSlot("payment_recipient", mappings=[]), + AnySlot("dialogue_stack", mappings=[]), + ], + ) + + action_run_slot_rejections = ActionRunSlotRejections() + events = await action_run_slot_rejections.run( + output_channel=default_channel, + nlg=rejection_test_nlg, + tracker=tracker, + domain=rejection_test_domain, + ) + + assert events == [] + + +async def test_action_run_slot_rejections_top_frame_slot_not_been_set( + default_channel: OutputChannel, + rejection_test_nlg: TemplatedNaturalLanguageGenerator, + rejection_test_domain: Domain, + capsys: CaptureFixture, +) -> None: + dialogue_stack = [ + { + "frame_id": "4YL3KDBR", + "flow_id": "setup_recurrent_payment", + "step_id": "ask_payment_type", + "frame_type": "regular", + "type": "flow", + }, + { + "frame_id": "6Z7PSTRM", + "flow_id": "pattern_ask_collect_information", + "step_id": "start", + "collect_information": "recurrent_payment_type", + "rejections": [ + { + "if": 'not ({"direct debit" "standing order"} contains recurrent_payment_type)', # noqa: E501 + "utter": "utter_invalid_recurrent_payment_type", + } + ], + "type": "pattern_collect_information", + }, + ] + + tracker = DialogueStateTracker.from_events( + sender_id=uuid.uuid4().hex, + evts=[ + UserUttered("i want to setup a new recurrent payment."), + SlotSet("dialogue_stack", dialogue_stack), + ], + slots=[ + TextSlot("recurrent_payment_type", mappings=[]), + AnySlot("dialogue_stack", mappings=[]), + ], + ) + + action_run_slot_rejections = ActionRunSlotRejections() + events = await action_run_slot_rejections.run( + output_channel=default_channel, + nlg=rejection_test_nlg, + tracker=tracker, + domain=rejection_test_domain, + ) + + assert events == [] + out = capsys.readouterr().out + assert "[debug ] first.collect.slot.not.set" in out + + +async def test_action_run_slot_rejections_run_success( + default_channel: OutputChannel, + rejection_test_nlg: TemplatedNaturalLanguageGenerator, + rejection_test_domain: Domain, +) -> None: + dialogue_stack = [ + { + "frame_id": "4YL3KDBR", + "flow_id": "setup_recurrent_payment", + "step_id": "ask_payment_type", + "frame_type": "regular", + "type": "flow", + }, + { + "frame_id": "6Z7PSTRM", + "flow_id": "pattern_ask_collect_information", + "step_id": "start", + "collect_information": "recurrent_payment_type", + "rejections": [ + { + "if": 'not ({"direct debit" "standing order"} contains recurrent_payment_type)', # noqa: E501 + "utter": "utter_invalid_recurrent_payment_type", + } + ], + "type": "pattern_collect_information", + }, + ] + tracker = DialogueStateTracker.from_events( + sender_id=uuid.uuid4().hex, + evts=[ + UserUttered("i want to setup an international transfer."), + SlotSet("recurrent_payment_type", "international transfer"), + SlotSet("dialogue_stack", dialogue_stack), + ], + slots=[ + TextSlot("recurrent_payment_type", mappings=[]), + AnySlot("dialogue_stack", mappings=[]), + ], + ) + + action_run_slot_rejections = ActionRunSlotRejections() + events = await action_run_slot_rejections.run( + output_channel=default_channel, + nlg=rejection_test_nlg, + tracker=tracker, + domain=rejection_test_domain, + ) + + assert events == [ + SlotSet("recurrent_payment_type", None), + BotUttered( + "Sorry, you requested an invalid recurrent payment type.", + metadata={"utter_action": "utter_invalid_recurrent_payment_type"}, + ), + ] + + +@pytest.mark.parametrize( + "predicate", [None, "recurrent_payment_type in {'direct debit', 'standing order'}"] +) +async def test_action_run_slot_rejections_internal_error( + predicate: Optional[Text], + default_channel: OutputChannel, + rejection_test_nlg: TemplatedNaturalLanguageGenerator, + rejection_test_domain: Domain, + capsys: CaptureFixture, +) -> None: + """Test that an invalid or None predicate dispatches an internal error utterance.""" + dialogue_stack = [ + { + "frame_id": "4YL3KDBR", + "flow_id": "setup_recurrent_payment", + "step_id": "ask_payment_type", + "frame_type": "regular", + "type": "flow", + }, + { + "frame_id": "6Z7PSTRM", + "flow_id": "pattern_ask_collect_information", + "step_id": "start", + "collect_information": "recurrent_payment_type", + "rejections": [ + { + "if": predicate, + "utter": "utter_invalid_recurrent_payment_type", + } + ], + "type": "pattern_collect_information", + }, + ] + + tracker = DialogueStateTracker.from_events( + sender_id=uuid.uuid4().hex, + evts=[ + UserUttered("i want to setup a new recurrent payment."), + SlotSet("recurrent_payment_type", "international transfer"), + SlotSet("dialogue_stack", dialogue_stack), + ], + slots=[ + TextSlot("recurrent_payment_type", mappings=[]), + AnySlot("dialogue_stack", mappings=[]), + ], + ) + + action_run_slot_rejections = ActionRunSlotRejections() + events = await action_run_slot_rejections.run( + output_channel=default_channel, + nlg=rejection_test_nlg, + tracker=tracker, + domain=rejection_test_domain, + ) + + assert events[0] == SlotSet("recurrent_payment_type", None) + assert isinstance(events[1], BotUttered) + assert events[1].text == "Sorry, something went wrong." + assert events[1].metadata == {"utter_action": "utter_internal_error_rasa"} + + out = capsys.readouterr().out + assert "[error ] run.predicate.error" in out + assert f"predicate={predicate}" in out + + +async def test_action_run_slot_rejections_collect_missing_utter( + default_channel: OutputChannel, + rejection_test_nlg: TemplatedNaturalLanguageGenerator, + rejection_test_domain: Domain, + capsys: CaptureFixture, +) -> None: + dialogue_stack = [ + { + "frame_id": "4YL3KDBR", + "flow_id": "setup_recurrent_payment", + "step_id": "ask_payment_type", + "frame_type": "regular", + "type": "flow", + }, + { + "frame_id": "6Z7PSTRM", + "flow_id": "pattern_ask_collect_information", + "step_id": "start", + "collect_information": "recurrent_payment_type", + "rejections": [ + { + "if": 'not ({"direct debit" "standing order"} contains recurrent_payment_type)', # noqa: E501 + "utter": None, + } + ], + "type": "pattern_collect_information", + }, + ] + + tracker = DialogueStateTracker.from_events( + sender_id=uuid.uuid4().hex, + evts=[ + UserUttered("i want to setup a new recurrent payment."), + SlotSet("recurrent_payment_type", "international transfer"), + SlotSet("dialogue_stack", dialogue_stack), + ], + slots=[ + TextSlot("recurrent_payment_type", mappings=[]), + AnySlot("dialogue_stack", mappings=[]), + ], + ) + + action_run_slot_rejections = ActionRunSlotRejections() + events = await action_run_slot_rejections.run( + output_channel=default_channel, + nlg=rejection_test_nlg, + tracker=tracker, + domain=rejection_test_domain, + ) + + assert events == [SlotSet("recurrent_payment_type", None)] + + out = capsys.readouterr().out + assert "[error ] run.rejection.missing.utter" in out + assert "utterance=None" in out + + +async def test_action_run_slot_rejections_not_found_utter( + default_channel: OutputChannel, + rejection_test_nlg: TemplatedNaturalLanguageGenerator, + rejection_test_domain: Domain, + capsys: CaptureFixture, +) -> None: + dialogue_stack = [ + { + "frame_id": "4YL3KDBR", + "flow_id": "setup_recurrent_payment", + "step_id": "ask_payment_type", + "frame_type": "regular", + "type": "flow", + }, + { + "frame_id": "6Z7PSTRM", + "flow_id": "pattern_ask_collect_information", + "step_id": "start", + "collect_information": "recurrent_payment_type", + "rejections": [ + { + "if": 'not ({"direct debit" "standing order"} contains recurrent_payment_type)', # noqa: E501 + "utter": "utter_not_found", + } + ], + "type": "pattern_collect_information", + }, + ] + + tracker = DialogueStateTracker.from_events( + sender_id=uuid.uuid4().hex, + evts=[ + UserUttered("i want to setup a new recurrent payment."), + SlotSet("recurrent_payment_type", "international transfer"), + SlotSet("dialogue_stack", dialogue_stack), + ], + slots=[ + TextSlot("recurrent_payment_type", mappings=[]), + AnySlot("dialogue_stack", mappings=[]), + ], + ) + + action_run_slot_rejections = ActionRunSlotRejections() + events = await action_run_slot_rejections.run( + output_channel=default_channel, + nlg=rejection_test_nlg, + tracker=tracker, + domain=rejection_test_domain, + ) + + assert events == [SlotSet("recurrent_payment_type", None)] + + out = capsys.readouterr().out + assert "[error ] run.rejection.failed.finding.utter" in out + assert "utterance=utter_not_found" in out + + +async def test_action_run_slot_rejections_pass_multiple_rejection_checks( + default_channel: OutputChannel, + rejection_test_nlg: TemplatedNaturalLanguageGenerator, + rejection_test_domain: Domain, + capsys: CaptureFixture, +) -> None: + dialogue_stack = [ + { + "frame_id": "4YL3KDBR", + "flow_id": "setup_recurrent_payment", + "step_id": "ask_payment_amount", + "frame_type": "regular", + "type": "flow", + }, + { + "frame_id": "6Z7PSTRM", + "flow_id": "pattern_ask_collect_information", + "step_id": "start", + "collect_information": "payment_amount", + "rejections": [ + { + "if": "payment_amount > 1000", + "utter": "utter_payment_too_high", + }, + { + "if": "payment_amount < 0", + "utter": "utter_payment_negative", + }, + ], + "type": "pattern_collect_information", + }, + ] + + tracker = DialogueStateTracker.from_events( + sender_id=uuid.uuid4().hex, + evts=[ + UserUttered("i want to transfer £500."), + SlotSet("payment_amount", 500), + SlotSet("dialogue_stack", dialogue_stack), + ], + slots=[ + FloatSlot("payment_amount", mappings=[]), + AnySlot("dialogue_stack", mappings=[]), + ], + ) + + action_run_slot_rejections = ActionRunSlotRejections() + events = await action_run_slot_rejections.run( + output_channel=default_channel, + nlg=rejection_test_nlg, + tracker=tracker, + domain=rejection_test_domain, + ) + + assert events == [] + assert tracker.get_slot("payment_amount") == 500 + + +async def test_action_run_slot_rejections_fails_multiple_rejection_checks( + default_channel: OutputChannel, + rejection_test_nlg: TemplatedNaturalLanguageGenerator, + rejection_test_domain: Domain, + capsys: CaptureFixture, +) -> None: + dialogue_stack = [ + { + "frame_id": "4YL3KDBR", + "flow_id": "setup_recurrent_payment", + "step_id": "ask_payment_amount", + "frame_type": "regular", + "type": "flow", + }, + { + "frame_id": "6Z7PSTRM", + "flow_id": "pattern_ask_collect_information", + "step_id": "start", + "collect_information": "payment_amount", + "rejections": [ + { + "if": "payment_amount > 1000", + "utter": "utter_payment_too_high", + }, + { + "if": "payment_amount < 0", + "utter": "utter_payment_negative", + }, + ], + "type": "pattern_collect_information", + }, + ] + + tracker = DialogueStateTracker.from_events( + sender_id=uuid.uuid4().hex, + evts=[ + UserUttered("i want to transfer $-100."), + SlotSet("payment_amount", -100), + SlotSet("dialogue_stack", dialogue_stack), + ], + slots=[ + FloatSlot("payment_amount", mappings=[]), + AnySlot("dialogue_stack", mappings=[]), + ], + ) + + action_run_slot_rejections = ActionRunSlotRejections() + events = await action_run_slot_rejections.run( + output_channel=default_channel, + nlg=rejection_test_nlg, + tracker=tracker, + domain=rejection_test_domain, + ) + + assert events == [ + SlotSet("payment_amount", None), + BotUttered( + "Sorry, the amount cannot be negative.", + metadata={"utter_action": "utter_payment_negative"}, + ), + ] diff --git a/tests/core/featurizers/test_tracker_featurizer.py b/tests/core/featurizers/test_tracker_featurizer.py index 8f0e1a062136..c65bf18e1a60 100644 --- a/tests/core/featurizers/test_tracker_featurizer.py +++ b/tests/core/featurizers/test_tracker_featurizer.py @@ -186,7 +186,7 @@ def test_featurize_trackers_with_full_dialogue_tracker_featurizer( for actual, expected in zip(actual_features, expected_features): assert compare_featurized_states(actual, expected) - expected_labels = np.array([[0, 20, 0, 17, 18, 0, 19]]) + expected_labels = np.array([[0, 21, 0, 18, 19, 0, 20]]) assert actual_labels is not None assert len(actual_labels) == 1 for actual, expected in zip(actual_labels, expected_labels): @@ -255,7 +255,7 @@ def test_trackers_ignore_action_unlikely_intent_with_full_dialogue_tracker_featu for actual, expected in zip(actual_features, expected_features): assert compare_featurized_states(actual, expected) - expected_labels = np.array([[0, 20, 0, 17, 18, 0, 19]]) + expected_labels = np.array([[0, 21, 0, 18, 19, 0, 20]]) assert actual_labels is not None assert len(actual_labels) == 1 for actual, expected in zip(actual_labels, expected_labels): @@ -324,7 +324,7 @@ def test_trackers_keep_action_unlikely_intent_with_full_dialogue_tracker_featuri for actual, expected in zip(actual_features, expected_features): assert compare_featurized_states(actual, expected) - expected_labels = np.array([[0, 9, 20, 0, 9, 17, 18, 0, 9, 19]]) + expected_labels = np.array([[0, 9, 21, 0, 9, 18, 19, 0, 9, 20]]) assert actual_labels is not None assert len(actual_labels) == 1 for actual, expected in zip(actual_labels, expected_labels): @@ -832,7 +832,7 @@ def test_featurize_trackers_with_max_history_tracker_featurizer( for actual, expected in zip(actual_features, expected_features): assert compare_featurized_states(actual, expected) - expected_labels = np.array([[0, 20, 0, 17, 18, 0, 19]]).T + expected_labels = np.array([[0, 21, 0, 18, 19, 0, 20]]).T assert actual_labels is not None assert actual_labels.shape == expected_labels.shape @@ -899,7 +899,7 @@ def test_featurize_trackers_ignore_action_unlikely_intent_max_history_featurizer for actual, expected in zip(actual_features, expected_features): assert compare_featurized_states(actual, expected) - expected_labels = np.array([[0, 20, 0]]).T + expected_labels = np.array([[0, 21, 0]]).T assert actual_labels.shape == expected_labels.shape for actual, expected in zip(actual_labels, expected_labels): assert np.all(actual == expected) @@ -971,7 +971,7 @@ def test_featurize_trackers_keep_action_unlikely_intent_max_history_featurizer( for actual, expected in zip(actual_features, expected_features): assert compare_featurized_states(actual, expected) - expected_labels = np.array([[0, 9, 20, 0]]).T + expected_labels = np.array([[0, 9, 21, 0]]).T assert actual_labels is not None assert actual_labels.shape == expected_labels.shape for actual, expected in zip(actual_labels, expected_labels): @@ -1088,7 +1088,7 @@ def test_deduplicate_featurize_trackers_with_max_history_tracker_featurizer( for actual, expected in zip(actual_features, expected_features): assert compare_featurized_states(actual, expected) - expected_labels = np.array([[0, 20, 0, 17, 18, 0, 19]]).T + expected_labels = np.array([[0, 21, 0, 18, 19, 0, 20]]).T if not remove_duplicates: expected_labels = np.vstack([expected_labels] * 2) diff --git a/tests/core/test_actions.py b/tests/core/test_actions.py index 6c492c22bbb1..c240d065f775 100644 --- a/tests/core/test_actions.py +++ b/tests/core/test_actions.py @@ -75,6 +75,7 @@ ACTION_CANCEL_FLOW, ACTION_CLARIFY_FLOWS, ACTION_CORRECT_FLOW_SLOT, + ACTION_RUN_SLOT_REJECTIONS_NAME, USER_INTENT_SESSION_START, ACTION_LISTEN_NAME, ACTION_RESTART_NAME, @@ -145,7 +146,7 @@ def test_domain_action_instantiation(): for action_name in domain.action_names_or_texts ] - assert len(instantiated_actions) == 20 + assert len(instantiated_actions) == 21 assert instantiated_actions[0].name() == ACTION_LISTEN_NAME assert instantiated_actions[1].name() == ACTION_RESTART_NAME assert instantiated_actions[2].name() == ACTION_SESSION_START_NAME @@ -163,9 +164,10 @@ def test_domain_action_instantiation(): assert instantiated_actions[14].name() == ACTION_CANCEL_FLOW assert instantiated_actions[15].name() == ACTION_CORRECT_FLOW_SLOT assert instantiated_actions[16].name() == ACTION_CLARIFY_FLOWS - assert instantiated_actions[17].name() == "my_module.ActionTest" - assert instantiated_actions[18].name() == "utter_test" - assert instantiated_actions[19].name() == "utter_chitchat" + assert instantiated_actions[17].name() == ACTION_RUN_SLOT_REJECTIONS_NAME + assert instantiated_actions[18].name() == "my_module.ActionTest" + assert instantiated_actions[19].name() == "utter_test" + assert instantiated_actions[20].name() == "utter_chitchat" @pytest.mark.parametrize( diff --git a/tests/shared/core/training_data/story_reader/test_yaml_story_reader.py b/tests/shared/core/training_data/story_reader/test_yaml_story_reader.py index 0900f68913c4..1bbb08bdd7d0 100644 --- a/tests/shared/core/training_data/story_reader/test_yaml_story_reader.py +++ b/tests/shared/core/training_data/story_reader/test_yaml_story_reader.py @@ -780,7 +780,7 @@ def test_generate_training_data_with_cycles(domain: Domain): # if new default actions are added the keys of the actions will be changed all_label_ids = [id for ids in label_ids for id in ids] - assert Counter(all_label_ids) == {0: 6, 20: 1, 18: num_tens, 1: 2, 19: 3} + assert Counter(all_label_ids) == {0: 6, 20: 3, 19: num_tens, 1: 2, 21: 1} def test_generate_training_data_with_unused_checkpoints(domain: Domain): diff --git a/tests/shared/importers/test_rasa.py b/tests/shared/importers/test_rasa.py index 1d521c09bcc3..88025312edf9 100644 --- a/tests/shared/importers/test_rasa.py +++ b/tests/shared/importers/test_rasa.py @@ -35,6 +35,7 @@ def test_rasa_file_importer(project: Text): AnySlot(RETURN_VALUE_SLOT, mappings=[{}]), AnySlot(SESSION_START_METADATA_SLOT, mappings=[{}]), ] + assert domain.entities == [] assert len(domain.action_names_or_texts) == 6 + len(DEFAULT_ACTION_NAMES) assert len(domain.responses) == 6 From 981e6e38ce25c4ad3c48134f321e9b4ffb4f9873 Mon Sep 17 00:00:00 2001 From: Alan Nichol Date: Fri, 22 Sep 2023 11:38:55 +0200 Subject: [PATCH 2/5] rename custom action in tutorial project --- rasa/cli/project_templates/tutorial/actions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rasa/cli/project_templates/tutorial/actions.py b/rasa/cli/project_templates/tutorial/actions.py index f023605fe014..312c3a47c5d5 100644 --- a/rasa/cli/project_templates/tutorial/actions.py +++ b/rasa/cli/project_templates/tutorial/actions.py @@ -4,9 +4,9 @@ from rasa_sdk.events import SlotSet -class ActionSufficientFunds(Action): +class ActionCheckSufficientFunds(Action): def name(self) -> Text: - return "action_sufficient_funds" + return "action_check_sufficient_funds" def run( self, From cd31c17fe29b8089a418f33ae377631a5867912a Mon Sep 17 00:00:00 2001 From: Alan Nichol Date: Fri, 22 Sep 2023 11:59:30 +0200 Subject: [PATCH 3/5] rm hardcoded dollar sign --- rasa/cli/project_templates/tutorial/domain.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rasa/cli/project_templates/tutorial/domain.yml b/rasa/cli/project_templates/tutorial/domain.yml index e06882f8f3d1..dc2854563712 100644 --- a/rasa/cli/project_templates/tutorial/domain.yml +++ b/rasa/cli/project_templates/tutorial/domain.yml @@ -18,4 +18,4 @@ responses: - text: "How much money would you like to send?" utter_transfer_complete: - - text: "All done. ${amount} has been sent to {recipient}." + - text: "All done. {amount} has been sent to {recipient}." From 856924fa604024247ce2781aab30589e532c06bd Mon Sep 17 00:00:00 2001 From: Tom Bocklisch Date: Fri, 22 Sep 2023 14:49:51 +0200 Subject: [PATCH 4/5] fixed ci issue --- .github/workflows/continous-integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continous-integration.yml b/.github/workflows/continous-integration.yml index 8813c17b9006..daec3ee4ce83 100644 --- a/.github/workflows/continous-integration.yml +++ b/.github/workflows/continous-integration.yml @@ -906,7 +906,7 @@ jobs: # Base MITIE image BASE_MITIE_IMAGE_HASH=${{ hashFiles('docker/Dockerfile.base-mitie') }} MAKEFILE_MITIE_HASH=${{ hashFiles('Makefile') }} - echo "base_mitie_image_hash=${BASE_MITIE_IMAGE_HASH}" >> $GITHUB_OUTPUT + echo "base_mitie_image_hash=${BASE_MITIE_IMAGE_HASH:0:50}-${MAKEFILE_MITIE_HASH:0:50}" >> $GITHUB_OUTPUT BASE_IMAGE_MITIE_EXISTS=$((docker manifest inspect rasa/rasa:base-mitie-${BASE_MITIE_IMAGE_HASH:0:50}-${MAKEFILE_MITIE_HASH:0:50} &> /dev/null && echo true || echo false) || true) echo "base_mitie_exists=${BASE_IMAGE_MITIE_EXISTS}" >> $GITHUB_OUTPUT From ed71677a3e68d557d838d564bd81f5f5a648ef71 Mon Sep 17 00:00:00 2001 From: Anca Lita <27920906+ancalita@users.noreply.github.com> Date: Fri, 22 Sep 2023 15:15:28 +0100 Subject: [PATCH 5/5] Add ability to optionally specify different response for a collect information step (#12849) * implement changes * fix tests --- rasa/core/policies/flow_policy.py | 4 +++- .../patterns/collect_information.py | 4 ++++ .../patterns/default_flows_for_patterns.yml | 2 +- rasa/shared/core/flows/flow.py | 6 ++++++ tests/cdu/stack/test_dialogue_stack.py | 11 ++++++++--- tests/core/actions/test_action_run_slot_rejections.py | 8 ++++++++ 6 files changed, 30 insertions(+), 5 deletions(-) diff --git a/rasa/core/policies/flow_policy.py b/rasa/core/policies/flow_policy.py index ede3003a87d9..924e9ec32984 100644 --- a/rasa/core/policies/flow_policy.py +++ b/rasa/core/policies/flow_policy.py @@ -554,7 +554,7 @@ def _run_step( if isinstance(step, CollectInformationFlowStep): structlogger.debug("flow.step.run.collect_information") self.trigger_pattern_ask_collect_information( - step.collect_information, step.rejections + step.collect_information, step.rejections, step.utter ) # reset the slot if its already filled and the collect information shouldn't @@ -686,11 +686,13 @@ def trigger_pattern_ask_collect_information( self, collect_information: str, rejections: List[SlotRejection], + utter: str, ) -> None: """Trigger the pattern to ask for a slot value.""" self.dialogue_stack.push( CollectInformationPatternFlowStackFrame( collect_information=collect_information, + utter=utter, rejections=rejections, ) ) diff --git a/rasa/dialogue_understanding/patterns/collect_information.py b/rasa/dialogue_understanding/patterns/collect_information.py index f56c3bfe2c3d..6653f06a1e2d 100644 --- a/rasa/dialogue_understanding/patterns/collect_information.py +++ b/rasa/dialogue_understanding/patterns/collect_information.py @@ -21,6 +21,9 @@ class CollectInformationPatternFlowStackFrame(PatternFlowStackFrame): collect_information: str = "" """The information that should be collected from the user. this corresponds to the slot that will be filled.""" + utter: str = "" + """The utter action that should be executed to ask the user for the + information.""" rejections: Optional[List[SlotRejection]] = None """The predicate check that should be applied to the collected information. If a predicate check fails, its `utter` action indicated under rejections @@ -52,6 +55,7 @@ def from_dict(data: Dict[str, Any]) -> CollectInformationPatternFlowStackFrame: data["frame_id"], step_id=data["step_id"], collect_information=data["collect_information"], + utter=data["utter"], rejections=rejections, ) diff --git a/rasa/dialogue_understanding/patterns/default_flows_for_patterns.yml b/rasa/dialogue_understanding/patterns/default_flows_for_patterns.yml index 93373798b4d3..2ca58f1afa33 100644 --- a/rasa/dialogue_understanding/patterns/default_flows_for_patterns.yml +++ b/rasa/dialogue_understanding/patterns/default_flows_for_patterns.yml @@ -119,7 +119,7 @@ flows: then: "done" - else: "ask_collect_information" - id: "ask_collect_information" - action: utter_ask_{{context.collect_information}} + action: "{{context.utter}}" next: "listen" - id: "listen" action: action_listen diff --git a/rasa/shared/core/flows/flow.py b/rasa/shared/core/flows/flow.py index e5b470ada4f3..63204c6f2eea 100644 --- a/rasa/shared/core/flows/flow.py +++ b/rasa/shared/core/flows/flow.py @@ -1023,6 +1023,8 @@ class CollectInformationFlowStep(FlowStep): collect_information: Text """The collect information of the flow step.""" + utter: Text + """The utterance that the assistant uses to ask for the slot.""" rejections: List[SlotRejection] """how the slot value is validated using predicate evaluation.""" ask_before_filling: bool = False @@ -1043,6 +1045,9 @@ def from_json(cls, flow_step_config: Dict[Text, Any]) -> CollectInformationFlowS base = super()._from_json(flow_step_config) return CollectInformationFlowStep( collect_information=flow_step_config.get("collect_information", ""), + utter=flow_step_config.get( + "utter", f"utter_ask_{flow_step_config['collect_information']}" + ), ask_before_filling=flow_step_config.get("ask_before_filling", False), scope=CollectInformationScope.from_str(flow_step_config.get("scope")), rejections=[ @@ -1060,6 +1065,7 @@ def as_json(self) -> Dict[Text, Any]: """ dump = super().as_json() dump["collect_information"] = self.collect_information + dump["utter"] = self.utter dump["ask_before_filling"] = self.ask_before_filling dump["scope"] = self.scope.value dump["rejections"] = [rejection.as_dict() for rejection in self.rejections] diff --git a/tests/cdu/stack/test_dialogue_stack.py b/tests/cdu/stack/test_dialogue_stack.py index 96c3fa2dcbad..a66e1aa93dc6 100644 --- a/tests/cdu/stack/test_dialogue_stack.py +++ b/tests/cdu/stack/test_dialogue_stack.py @@ -21,6 +21,7 @@ def test_dialogue_stack_from_dict(): "frame_id": "some-other-id", "step_id": "__start__", "flow_id": "pattern_ask_collect_information", + "utter": "utter_ask_foo", }, ] ) @@ -31,7 +32,7 @@ def test_dialogue_stack_from_dict(): flow_id="foo", step_id="first_step", frame_id="some-frame-id" ) assert stack.frames[1] == CollectInformationPatternFlowStackFrame( - collect_information="foo", frame_id="some-other-id" + collect_information="foo", frame_id="some-other-id", utter="utter_ask_foo" ) @@ -47,7 +48,9 @@ def test_dialogue_stack_as_dict(): flow_id="foo", step_id="first_step", frame_id="some-frame-id" ), CollectInformationPatternFlowStackFrame( - collect_information="foo", frame_id="some-other-id" + collect_information="foo", + frame_id="some-other-id", + utter="utter_ask_foo", ), ] ) @@ -67,6 +70,7 @@ def test_dialogue_stack_as_dict(): "step_id": "__start__", "flow_id": "pattern_ask_collect_information", "rejections": None, + "utter": "utter_ask_foo", }, ] @@ -189,7 +193,7 @@ def test_get_current_context(): flow_id="foo", step_id="first_step", frame_id="some-frame-id" ) pattern_frame = CollectInformationPatternFlowStackFrame( - collect_information="foo", frame_id="some-other-id" + collect_information="foo", frame_id="some-other-id", utter="utter_ask_foo" ) stack = DialogueStack(frames=[]) @@ -202,6 +206,7 @@ def test_get_current_context(): "step_id": "first_step", "type": "flow", "collect_information": "foo", + "utter": "utter_ask_foo", "rejections": None, } diff --git a/tests/core/actions/test_action_run_slot_rejections.py b/tests/core/actions/test_action_run_slot_rejections.py index d22169acc65c..c8e97d8521b2 100644 --- a/tests/core/actions/test_action_run_slot_rejections.py +++ b/tests/core/actions/test_action_run_slot_rejections.py @@ -122,6 +122,7 @@ async def test_action_run_slot_rejections_top_frame_none_rejections( "flow_id": "pattern_ask_collect_information", "step_id": "start", "collect_information": "payment_recipient", + "utter": "utter_ask_payment_recipient", "rejections": [], "type": "pattern_collect_information", }, @@ -169,6 +170,7 @@ async def test_action_run_slot_rejections_top_frame_slot_not_been_set( "flow_id": "pattern_ask_collect_information", "step_id": "start", "collect_information": "recurrent_payment_type", + "utter": "utter_ask_recurrent_payment_type", "rejections": [ { "if": 'not ({"direct debit" "standing order"} contains recurrent_payment_type)', # noqa: E501 @@ -222,6 +224,7 @@ async def test_action_run_slot_rejections_run_success( "flow_id": "pattern_ask_collect_information", "step_id": "start", "collect_information": "recurrent_payment_type", + "utter": "utter_ask_recurrent_payment_type", "rejections": [ { "if": 'not ({"direct debit" "standing order"} contains recurrent_payment_type)', # noqa: E501 @@ -285,6 +288,7 @@ async def test_action_run_slot_rejections_internal_error( "flow_id": "pattern_ask_collect_information", "step_id": "start", "collect_information": "recurrent_payment_type", + "utter": "utter_ask_recurrent_payment_type", "rejections": [ { "if": predicate, @@ -345,6 +349,7 @@ async def test_action_run_slot_rejections_collect_missing_utter( "flow_id": "pattern_ask_collect_information", "step_id": "start", "collect_information": "recurrent_payment_type", + "utter": "utter_ask_recurrent_payment_type", "rejections": [ { "if": 'not ({"direct debit" "standing order"} contains recurrent_payment_type)', # noqa: E501 @@ -402,6 +407,7 @@ async def test_action_run_slot_rejections_not_found_utter( "flow_id": "pattern_ask_collect_information", "step_id": "start", "collect_information": "recurrent_payment_type", + "utter": "utter_ask_recurrent_payment_type", "rejections": [ { "if": 'not ({"direct debit" "standing order"} contains recurrent_payment_type)', # noqa: E501 @@ -459,6 +465,7 @@ async def test_action_run_slot_rejections_pass_multiple_rejection_checks( "flow_id": "pattern_ask_collect_information", "step_id": "start", "collect_information": "payment_amount", + "utter": "utter_ask_payment_amount", "rejections": [ { "if": "payment_amount > 1000", @@ -517,6 +524,7 @@ async def test_action_run_slot_rejections_fails_multiple_rejection_checks( "flow_id": "pattern_ask_collect_information", "step_id": "start", "collect_information": "payment_amount", + "utter": "utter_ask_payment_amount", "rejections": [ { "if": "payment_amount > 1000",