From 4f30859e747473b69470d6a18d617801b5911690 Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Tue, 24 Oct 2023 11:54:27 +0200 Subject: [PATCH 01/31] Moved exceptions --- rasa/shared/core/flows/exceptions.py | 132 ++++++++++++++++++++++ rasa/shared/core/flows/flow.py | 157 ++++----------------------- 2 files changed, 152 insertions(+), 137 deletions(-) create mode 100644 rasa/shared/core/flows/exceptions.py diff --git a/rasa/shared/core/flows/exceptions.py b/rasa/shared/core/flows/exceptions.py new file mode 100644 index 000000000000..c990d86b49fc --- /dev/null +++ b/rasa/shared/core/flows/exceptions.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from typing import Text, Optional + +from rasa.shared.exceptions import RasaException + + +class UnreachableFlowStepException(RasaException): + """Raised when a flow step is unreachable.""" + + def __init__(self, step_id: str, flow_id: str) -> None: + """Initializes the exception.""" + self.step_id = step_id + self.flow_id = flow_id + + def __str__(self) -> Text: + """Return a string representation of the exception.""" + return ( + f"Step '{self.step_id}' in flow '{self.flow_id}' can not be reached " + f"from the start step. Please make sure that all steps can be reached " + f"from the start step, e.g. by " + f"checking that another step points to this step." + ) + + +class MissingNextLinkException(RasaException): + """Raised when a flow step is missing a next link.""" + + def __init__(self, step_id: str, flow_id: str) -> None: + """Initializes the exception.""" + self.step_id = step_id + self.flow_id = flow_id + + def __str__(self) -> Text: + """Return a string representation of the exception.""" + return ( + f"Step '{self.step_id}' in flow '{self.flow_id}' is missing a `next`. " + f"As a last step of a branch, it is required to have one. " + ) + + +class ReservedFlowStepIdException(RasaException): + """Raised when a flow step is using a reserved id.""" + + def __init__(self, step_id: str, flow_id: str) -> None: + """Initializes the exception.""" + self.step_id = step_id + self.flow_id = flow_id + + def __str__(self) -> Text: + """Return a string representation of the exception.""" + return ( + f"Step '{self.step_id}' in flow '{self.flow_id}' is using the reserved id " + f"'{self.step_id}'. Please use a different id for your step." + ) + + +class MissingElseBranchException(RasaException): + """Raised when a flow step is missing an else branch.""" + + def __init__(self, step_id: str, flow_id: str) -> None: + """Initializes the exception.""" + self.step_id = step_id + self.flow_id = flow_id + + def __str__(self) -> Text: + """Return a string representation of the exception.""" + return ( + f"Step '{self.step_id}' in flow '{self.flow_id}' is missing an `else` " + f"branch. If a steps `next` statement contains an `if` it always " + f"also needs an `else` branch. Please add the missing `else` branch." + ) + + +class NoNextAllowedForLinkException(RasaException): + """Raised when a flow step has a next link but is not allowed to have one.""" + + def __init__(self, step_id: str, flow_id: str) -> None: + """Initializes the exception.""" + self.step_id = step_id + self.flow_id = flow_id + + def __str__(self) -> Text: + """Return a string representation of the exception.""" + return ( + f"Link step '{self.step_id}' in flow '{self.flow_id}' has a `next` but " + f"as a link step is not allowed to have one." + ) + + +class UnresolvedFlowStepIdException(RasaException): + """Raised when a flow step is referenced, but its id can not be resolved.""" + + def __init__( + self, step_id: str, flow_id: str, referenced_from_step_id: Optional[str] + ) -> None: + """Initializes the exception.""" + self.step_id = step_id + self.flow_id = flow_id + self.referenced_from_step_id = referenced_from_step_id + + def __str__(self) -> Text: + """Return a string representation of the exception.""" + if self.referenced_from_step_id: + exception_message = ( + f"Step with id '{self.step_id}' could not be resolved. " + f"'Step '{self.referenced_from_step_id}' in flow '{self.flow_id}' " + f"referenced this step but it does not exist. " + ) + else: + exception_message = ( + f"Step '{self.step_id}' in flow '{self.flow_id}' can not be resolved. " + ) + + return exception_message + ( + "Please make sure that the step is defined in the same flow." + ) + + +class UnresolvedFlowException(RasaException): + """Raised when a flow is referenced but it's id can not be resolved.""" + + def __init__(self, flow_id: Text) -> None: + """Initializes the exception.""" + self.flow_id = flow_id + + def __str__(self) -> Text: + """Return a string representation of the exception.""" + return ( + f"Flow '{self.flow_id}' can not be resolved. " + f"Please make sure that the flow is defined." + ) diff --git a/rasa/shared/core/flows/flow.py b/rasa/shared/core/flows/flow.py index 656309c23564..950ff41203df 100644 --- a/rasa/shared/core/flows/flow.py +++ b/rasa/shared/core/flows/flow.py @@ -16,9 +16,17 @@ ) import structlog +from rasa.shared.core.flows.exceptions import ( + UnreachableFlowStepException, + MissingNextLinkException, + ReservedFlowStepIdException, + MissingElseBranchException, + NoNextAllowedForLinkException, + UnresolvedFlowStepIdException, + UnresolvedFlowException, +) from rasa.shared.core.trackers import DialogueStateTracker from rasa.shared.constants import RASA_DEFAULT_FLOW_PATTERN_PREFIX, UTTER_PREFIX -from rasa.shared.exceptions import RasaException from rasa.shared.nlu.constants import ENTITY_ATTRIBUTE_TYPE, INTENT_NAME_KEY import rasa.shared.utils.io @@ -36,133 +44,6 @@ DEFAULT_STEPS = {END_STEP, START_STEP} -class UnreachableFlowStepException(RasaException): - """Raised when a flow step is unreachable.""" - - def __init__(self, step: FlowStep, flow: Flow) -> None: - """Initializes the exception.""" - self.step = step - self.flow = flow - - def __str__(self) -> Text: - """Return a string representation of the exception.""" - return ( - f"Step '{self.step.id}' in flow '{self.flow.id}' can not be reached " - f"from the start step. Please make sure that all steps can be reached " - f"from the start step, e.g. by " - f"checking that another step points to this step." - ) - - -class MissingNextLinkException(RasaException): - """Raised when a flow step is missing a next link.""" - - def __init__(self, step: FlowStep, flow: Flow) -> None: - """Initializes the exception.""" - self.step = step - self.flow = flow - - def __str__(self) -> Text: - """Return a string representation of the exception.""" - return ( - f"Step '{self.step.id}' in flow '{self.flow.id}' is missing a `next`. " - f"As a last step of a branch, it is required to have one. " - ) - - -class ReservedFlowStepIdException(RasaException): - """Raised when a flow step is using a reserved id.""" - - def __init__(self, step: FlowStep, flow: Flow) -> None: - """Initializes the exception.""" - self.step = step - self.flow = flow - - def __str__(self) -> Text: - """Return a string representation of the exception.""" - return ( - f"Step '{self.step.id}' in flow '{self.flow.id}' is using the reserved id " - f"'{self.step.id}'. Please use a different id for your step." - ) - - -class MissingElseBranchException(RasaException): - """Raised when a flow step is missing an else branch.""" - - def __init__(self, step: FlowStep, flow: Flow) -> None: - """Initializes the exception.""" - self.step = step - self.flow = flow - - def __str__(self) -> Text: - """Return a string representation of the exception.""" - return ( - f"Step '{self.step.id}' in flow '{self.flow.id}' is missing an `else` " - f"branch. If a steps `next` statement contains an `if` it always " - f"also needs an `else` branch. Please add the missing `else` branch." - ) - - -class NoNextAllowedForLinkException(RasaException): - """Raised when a flow step has a next link but is not allowed to have one.""" - - def __init__(self, step: FlowStep, flow: Flow) -> None: - """Initializes the exception.""" - self.step = step - self.flow = flow - - def __str__(self) -> Text: - """Return a string representation of the exception.""" - return ( - f"Link step '{self.step.id}' in flow '{self.flow.id}' has a `next` but " - f"as a link step is not allowed to have one." - ) - - -class UnresolvedFlowStepIdException(RasaException): - """Raised when a flow step is referenced, but its id can not be resolved.""" - - def __init__( - self, step_id: Text, flow: Flow, referenced_from: Optional[FlowStep] - ) -> None: - """Initializes the exception.""" - self.step_id = step_id - self.flow = flow - self.referenced_from = referenced_from - - def __str__(self) -> Text: - """Return a string representation of the exception.""" - if self.referenced_from: - exception_message = ( - f"Step with id '{self.step_id}' could not be resolved. " - f"'Step '{self.referenced_from.id}' in flow '{self.flow.id}' " - f"referenced this step but it does not exist. " - ) - else: - exception_message = ( - f"Step '{self.step_id}' in flow '{self.flow.id}' can not be resolved. " - ) - - return exception_message + ( - "Please make sure that the step is defined in the same flow." - ) - - -class UnresolvedFlowException(RasaException): - """Raised when a flow is referenced but it's id can not be resolved.""" - - def __init__(self, flow_id: Text) -> None: - """Initializes the exception.""" - self.flow_id = flow_id - - def __str__(self) -> Text: - """Return a string representation of the exception.""" - return ( - f"Flow '{self.flow_id}' can not be resolved. " - f"Please make sure that the flow is defined." - ) - - class FlowsList: """Represents the configuration of a list of flow. @@ -256,7 +137,9 @@ def step_by_id(self, step_id: Text, flow_id: Text) -> FlowStep: step = flow.step_by_id(step_id) if not step: - raise UnresolvedFlowStepIdException(step_id, flow, referenced_from=None) + raise UnresolvedFlowStepIdException( + step_id, flow.id, referenced_from_step_id=None + ) return step @@ -391,7 +274,7 @@ def validate(self) -> None: - whether all steps can be reached from the start step """ self._validate_all_steps_next_property() - self._validate_all_next_ids_are_availble_steps() + self._validate_all_next_ids_are_available_steps() self._validate_all_steps_can_be_reached() self._validate_all_branches_have_an_else() self._validate_not_using_buildin_ids() @@ -400,7 +283,7 @@ def _validate_not_using_buildin_ids(self) -> None: """Validates that the flow does not use any of the build in ids.""" for step in self.steps: if step.id in DEFAULT_STEPS or step.id.startswith(CONTINUE_STEP_PREFIX): - raise ReservedFlowStepIdException(step, self) + raise ReservedFlowStepIdException(step.id, self.id) def _validate_all_branches_have_an_else(self) -> None: """Validates that all branches have an else link.""" @@ -411,7 +294,7 @@ def _validate_all_branches_have_an_else(self) -> None: has_an_else = any(isinstance(link, ElseFlowLink) for link in links) if has_an_if and not has_an_else: - raise MissingElseBranchException(step, self) + raise MissingElseBranchException(step.id, self.id) def _validate_all_steps_next_property(self) -> None: """Validates that every step has a next link.""" @@ -419,18 +302,18 @@ def _validate_all_steps_next_property(self) -> None: if isinstance(step, LinkFlowStep): # link steps can't have a next link! if not step.next.no_link_available(): - raise NoNextAllowedForLinkException(step, self) + raise NoNextAllowedForLinkException(step.id, self.id) elif step.next.no_link_available(): # all other steps should have a next link - raise MissingNextLinkException(step, self) + raise MissingNextLinkException(step.id, self.id) - def _validate_all_next_ids_are_availble_steps(self) -> None: + def _validate_all_next_ids_are_available_steps(self) -> None: """Validates that all next links point to existing steps.""" available_steps = {step.id for step in self.steps} | DEFAULT_STEPS for step in self.steps: for link in step.next.links: if link.target not in available_steps: - raise UnresolvedFlowStepIdException(link.target, self, step) + raise UnresolvedFlowStepIdException(link.target, self.id, step.id) def _validate_all_steps_can_be_reached(self) -> None: """Validates that all steps can be reached from the start step.""" @@ -453,7 +336,7 @@ def _reachable_steps( for step in self.steps: if step.id not in reached_steps: - raise UnreachableFlowStepException(step, self) + raise UnreachableFlowStepException(step.id, self.id) def step_by_id(self, step_id: Optional[Text]) -> Optional[FlowStep]: """Returns the step with the given id.""" From dc78a0dee6f1b7e0630124e63c49aa40ae0c8b45 Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Tue, 24 Oct 2023 13:15:15 +0200 Subject: [PATCH 02/31] fixed typo --- rasa/graph_components/providers/flows_provider.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rasa/graph_components/providers/flows_provider.py b/rasa/graph_components/providers/flows_provider.py index 647249c69c50..777f676b992e 100644 --- a/rasa/graph_components/providers/flows_provider.py +++ b/rasa/graph_components/providers/flows_provider.py @@ -10,7 +10,7 @@ from rasa.shared.core.flows.flow import FlowsList -FLOWS_PERSITENCE_FILE_NAME = "flows.yml" +FLOWS_PERSISTENCE_FILE_NAME = "flows.yml" class FlowsProvider(GraphComponent): @@ -50,7 +50,7 @@ def load( """Creates provider using a persisted version of itself.""" with model_storage.read_from(resource) as resource_directory: flows = YAMLFlowsReader.read_from_file( - resource_directory / FLOWS_PERSITENCE_FILE_NAME + resource_directory / FLOWS_PERSISTENCE_FILE_NAME ) return cls(model_storage, resource, flows) @@ -59,7 +59,7 @@ def _persist(self, flows: FlowsList) -> None: with self._model_storage.write_to(self._resource) as resource_directory: YamlFlowsWriter.dump( flows.underlying_flows, - resource_directory / FLOWS_PERSITENCE_FILE_NAME, + resource_directory / FLOWS_PERSISTENCE_FILE_NAME, ) def provide_train(self, importer: TrainingDataImporter) -> FlowsList: From c154ae93c57454f22282ac76094b475e73ba96a4 Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Tue, 24 Oct 2023 13:15:26 +0200 Subject: [PATCH 03/31] fixed schema --- rasa/shared/core/flows/flows_yaml_schema.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rasa/shared/core/flows/flows_yaml_schema.json b/rasa/shared/core/flows/flows_yaml_schema.json index 3435b991310d..f5056596fc7b 100644 --- a/rasa/shared/core/flows/flows_yaml_schema.json +++ b/rasa/shared/core/flows/flows_yaml_schema.json @@ -19,7 +19,7 @@ "$defs": { "steps": { "type": "array", - "minContains": 1, + "minItems": 1, "items": { "type": "object", "oneOf": [ @@ -206,7 +206,7 @@ "anyOf": [ { "type": "array", - "minContains": 1, + "minItems": 1, "items": { "type": "object", "oneOf": [ From e0abaec30ce60f3ecddd4ca74bd3f39cd86e9bea Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Tue, 24 Oct 2023 13:56:11 +0200 Subject: [PATCH 04/31] added basic test for flow schema --- tests/core/flows/test_flow_schema.py | 53 ++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/core/flows/test_flow_schema.py diff --git a/tests/core/flows/test_flow_schema.py b/tests/core/flows/test_flow_schema.py new file mode 100644 index 000000000000..0f1c4a1e9015 --- /dev/null +++ b/tests/core/flows/test_flow_schema.py @@ -0,0 +1,53 @@ +import pytest + +from rasa.shared.core.flows.yaml_flows_io import flows_from_str +from rasa.shared.exceptions import SchemaValidationError + + +def test_schema_validation_fails_on_empty_steps() -> None: + with pytest.raises(SchemaValidationError): + flows_from_str( + """ + flows: + empty_flow: + description: "A flow without steps" + steps: [] + """ + ) + + +def test_schema_validation_fails_on_empty_steps_for_branch() -> None: + valid_flows = """ + flows: + empty_branch_flow: + description: "A flow with an empty branch" + steps: + - action: utter_greet + next: + - if: "status == logged_in" + then: + - action: utter_already_logged_in + next: "END" + - else: + - action: "utter_need_to_log_in" + next: "END" + """ + + flows_from_str(valid_flows) + + invalid_flows = """ + flows: + empty_branch_flow: + description: "A flow with an empty branch" + steps: + - action: utter_greet + next: + - if: "status == logged_in" + then: [] + - else: + - action: "utter_need_to_log_in" + next: "END" + """ + + with pytest.raises(SchemaValidationError): + flows_from_str(invalid_flows) From 7c2c4a1e1aa65048d5fd32e9f1e3ae52cfe00a66 Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Tue, 24 Oct 2023 14:15:43 +0200 Subject: [PATCH 05/31] replaced Text with str typing, typo --- rasa/shared/core/flows/exceptions.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/rasa/shared/core/flows/exceptions.py b/rasa/shared/core/flows/exceptions.py index c990d86b49fc..ac980872e4ec 100644 --- a/rasa/shared/core/flows/exceptions.py +++ b/rasa/shared/core/flows/exceptions.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Text, Optional +from typing import Optional from rasa.shared.exceptions import RasaException @@ -13,7 +13,7 @@ def __init__(self, step_id: str, flow_id: str) -> None: self.step_id = step_id self.flow_id = flow_id - def __str__(self) -> Text: + def __str__(self) -> str: """Return a string representation of the exception.""" return ( f"Step '{self.step_id}' in flow '{self.flow_id}' can not be reached " @@ -31,7 +31,7 @@ def __init__(self, step_id: str, flow_id: str) -> None: self.step_id = step_id self.flow_id = flow_id - def __str__(self) -> Text: + def __str__(self) -> str: """Return a string representation of the exception.""" return ( f"Step '{self.step_id}' in flow '{self.flow_id}' is missing a `next`. " @@ -47,7 +47,7 @@ def __init__(self, step_id: str, flow_id: str) -> None: self.step_id = step_id self.flow_id = flow_id - def __str__(self) -> Text: + def __str__(self) -> str: """Return a string representation of the exception.""" return ( f"Step '{self.step_id}' in flow '{self.flow_id}' is using the reserved id " @@ -63,7 +63,7 @@ def __init__(self, step_id: str, flow_id: str) -> None: self.step_id = step_id self.flow_id = flow_id - def __str__(self) -> Text: + def __str__(self) -> str: """Return a string representation of the exception.""" return ( f"Step '{self.step_id}' in flow '{self.flow_id}' is missing an `else` " @@ -80,7 +80,7 @@ def __init__(self, step_id: str, flow_id: str) -> None: self.step_id = step_id self.flow_id = flow_id - def __str__(self) -> Text: + def __str__(self) -> str: """Return a string representation of the exception.""" return ( f"Link step '{self.step_id}' in flow '{self.flow_id}' has a `next` but " @@ -99,7 +99,7 @@ def __init__( self.flow_id = flow_id self.referenced_from_step_id = referenced_from_step_id - def __str__(self) -> Text: + def __str__(self) -> str: """Return a string representation of the exception.""" if self.referenced_from_step_id: exception_message = ( @@ -118,13 +118,13 @@ def __str__(self) -> Text: class UnresolvedFlowException(RasaException): - """Raised when a flow is referenced but it's id can not be resolved.""" + """Raised when a flow is referenced, but its id cannot be resolved.""" - def __init__(self, flow_id: Text) -> None: + def __init__(self, flow_id: str) -> None: """Initializes the exception.""" self.flow_id = flow_id - def __str__(self) -> Text: + def __str__(self) -> str: """Return a string representation of the exception.""" return ( f"Flow '{self.flow_id}' can not be resolved. " From 0a66c7cf6e8ec571f54d255cb288d6e65a041040 Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Tue, 24 Oct 2023 16:21:38 +0200 Subject: [PATCH 06/31] Added exceptions, validations, and tests for empty flows and empty step sequences --- rasa/shared/core/flows/exceptions.py | 28 +++++++++++++++++++++++++++ rasa/shared/core/flows/flow.py | 16 +++++++++++++++ tests/core/flows/test_flow.py | 29 ++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+) diff --git a/rasa/shared/core/flows/exceptions.py b/rasa/shared/core/flows/exceptions.py index ac980872e4ec..7e63feda4126 100644 --- a/rasa/shared/core/flows/exceptions.py +++ b/rasa/shared/core/flows/exceptions.py @@ -130,3 +130,31 @@ def __str__(self) -> str: f"Flow '{self.flow_id}' can not be resolved. " f"Please make sure that the flow is defined." ) + + +class EmptyStepSequenceException(RasaException): + """Raised when an empty step sequence is encountered.""" + + def __init__(self, flow_id: str, step_id: str) -> None: + """Initializes the exception.""" + self.flow_id = flow_id + self.step_id = step_id + + def __str__(self) -> str: + """Return a string representation of the exception.""" + if not self.flow_id: + return "Encountered an empty step sequence." + else: + return f"Encountered an empty step sequence in flow '{self.flow_id}'." + + +class EmptyFlowException(RasaException): + """Raised when a flow is completely empty.""" + + def __init__(self, flow_id: str) -> None: + """Initializes the exception.""" + self.flow_id = flow_id + + def __str__(self) -> str: + """Return a string representation of the exception.""" + return f"Flow '{self.flow_id}' does not have any steps." diff --git a/rasa/shared/core/flows/flow.py b/rasa/shared/core/flows/flow.py index 950ff41203df..48fa6f74388e 100644 --- a/rasa/shared/core/flows/flow.py +++ b/rasa/shared/core/flows/flow.py @@ -24,6 +24,8 @@ NoNextAllowedForLinkException, UnresolvedFlowStepIdException, UnresolvedFlowException, + EmptyStepSequenceException, + EmptyFlowException, ) from rasa.shared.core.trackers import DialogueStateTracker from rasa.shared.constants import RASA_DEFAULT_FLOW_PATTERN_PREFIX, UTTER_PREFIX @@ -273,12 +275,26 @@ def validate(self) -> None: - whether all next links point to existing steps - whether all steps can be reached from the start step """ + self._validate_no_empty_flows() + self._validate_no_empty_step_sequences() self._validate_all_steps_next_property() self._validate_all_next_ids_are_available_steps() self._validate_all_steps_can_be_reached() self._validate_all_branches_have_an_else() self._validate_not_using_buildin_ids() + def _validate_no_empty_flows(self) -> None: + """Validate that the flow is not empty.""" + if len(self.steps) == 0: + raise EmptyFlowException(self.id) + + def _validate_no_empty_step_sequences(self) -> None: + """Validate that the flow does not have any empty step sequences.""" + for step in self.steps: + for link in step.next.links: + if isinstance(link, BranchBasedLink) and link.target is None: + raise EmptyStepSequenceException(self.id, step.id) + def _validate_not_using_buildin_ids(self) -> None: """Validates that the flow does not use any of the build in ids.""" for step in self.steps: diff --git a/tests/core/flows/test_flow.py b/tests/core/flows/test_flow.py index 87f4e2551b86..259363c73324 100644 --- a/tests/core/flows/test_flow.py +++ b/tests/core/flows/test_flow.py @@ -1,5 +1,9 @@ import pytest +from rasa.shared.core.flows.exceptions import ( + EmptyStepSequenceException, + EmptyFlowException, +) from rasa.shared.core.flows.flow import Flow, FlowsList from rasa.shared.importers.importer import FlowSyncImporter from tests.utilities import flows_from_str @@ -105,3 +109,28 @@ def test_default_flows_have_non_empty_names(): default_flows = FlowSyncImporter.load_default_pattern_flows() for flow in default_flows.underlying_flows: assert flow.name + + +def test_flow_from_json_with_empty_steps_raises(): + flow_as_dict = {"description": "a flow with empty steps", "steps": []} + flow = Flow.from_json("empty_flow", flow_as_dict) + with pytest.raises(EmptyFlowException) as e: + flow.validate() + assert e.value.flow_id == "empty_flow" + + +def test_flow_from_json_with_empty_branch_raises(): + flow_as_dict = { + "description": "a flow with empty steps", + "steps": [ + { + "action": "utter_something", + "next": [{"if": "some condition", "then": []}], + } + ], + } + flow = Flow.from_json("empty_branch_flow", flow_as_dict) + with pytest.raises(EmptyStepSequenceException) as e: + flow.validate() + assert e.value.flow_id == "empty_branch_flow" + assert "utter_something" in e.value.step_id From b5794224eae4b7bddd34f0731b5a1eea1126755a Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Tue, 24 Oct 2023 16:52:59 +0200 Subject: [PATCH 07/31] Using normal inheritance for FlowLink class hierarchy --- rasa/core/policies/flow_policy.py | 2 +- rasa/shared/core/flows/flow.py | 33 ++++++++++++++++++------------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/rasa/core/policies/flow_policy.py b/rasa/core/policies/flow_policy.py index fcd282e2233e..29ac09a88eb9 100644 --- a/rasa/core/policies/flow_policy.py +++ b/rasa/core/policies/flow_policy.py @@ -50,7 +50,6 @@ BranchFlowStep, ContinueFlowStep, ElseFlowLink, - EndFlowStep, Flow, FlowStep, FlowsList, @@ -64,6 +63,7 @@ CollectInformationFlowStep, StaticFlowLink, ) +from rasa.shared.core.flows.steps.internal import EndFlowStep from rasa.core.featurizers.tracker_featurizers import TrackerFeaturizer from rasa.core.policies.policy import Policy, PolicyPrediction from rasa.engine.graph import ExecutionContext diff --git a/rasa/shared/core/flows/flow.py b/rasa/shared/core/flows/flow.py index 48fa6f74388e..498b2728d078 100644 --- a/rasa/shared/core/flows/flow.py +++ b/rasa/shared/core/flows/flow.py @@ -240,9 +240,9 @@ def resolve_default_next(steps: List[FlowStep], is_root_sequence: bool) -> None: # if this is the root sequence, we need to add an end step # to the end of the sequence. other sequences, e.g. # in branches need to explicitly add a next step. - step.next.links.append(StaticFlowLink(target=END_STEP)) + step.next.links.append(StaticFlowLink(END_STEP)) else: - step.next.links.append(StaticFlowLink(target=steps[i + 1].id)) + step.next.links.append(StaticFlowLink(steps[i + 1].id)) for link in step.next.links: if sub_steps := link.child_steps(): resolve_default_next(sub_steps, is_root_sequence=False) @@ -656,7 +656,7 @@ def __init__(self, start_step_id: Optional[Text]) -> None: start_step: The step to start the flow from. """ if start_step_id is not None: - links: List[FlowLink] = [StaticFlowLink(target=start_step_id)] + links: List[FlowLink] = [StaticFlowLink(start_step_id)] else: links = [] @@ -705,7 +705,7 @@ def __init__(self, next: str) -> None: # if we want to "re-run" a step, we need to link to it again. # This is why the continue step links to the step that should be # continued. - next=FlowLinks(links=[StaticFlowLink(target=next)]), + next=FlowLinks(links=[StaticFlowLink(next)]), ) @staticmethod @@ -1256,7 +1256,7 @@ def steps_in_tree(self) -> Generator[FlowStep, None, None]: yield from link.steps_in_tree() -class FlowLink(Protocol): +class FlowLink: """Represents a flow link.""" @property @@ -1266,7 +1266,7 @@ def target(self) -> Optional[Text]: Returns: The target of the flow link. """ - ... + raise NotImplementedError() def as_json(self) -> Any: """Returns the flow link as a dictionary. @@ -1274,7 +1274,7 @@ def as_json(self) -> Any: Returns: The flow link as a dictionary. """ - ... + raise NotImplementedError() @staticmethod def from_json(link_config: Any) -> FlowLink: @@ -1286,19 +1286,19 @@ def from_json(link_config: Any) -> FlowLink: Returns: The parsed flow link. """ - ... + raise NotImplementedError() def steps_in_tree(self) -> Generator[FlowStep, None, None]: """Returns the steps in the tree of the flow link.""" - ... + raise NotImplementedError() def child_steps(self) -> List[FlowStep]: """Returns the child steps of the flow link.""" - ... + raise NotImplementedError() @dataclass -class BranchBasedLink: +class BranchBasedLink(FlowLink): target_reference: Union[Text, StepSequence] """The id of the linked flow.""" @@ -1402,10 +1402,10 @@ def as_json(self) -> Dict[Text, Any]: @dataclass -class StaticFlowLink: +class StaticFlowLink(FlowLink): """Represents the configuration of a static flow link.""" - target: Text + target_id: Text """The id of the linked flow.""" @staticmethod @@ -1418,7 +1418,7 @@ def from_json(link_config: Text) -> StaticFlowLink: Returns: The parsed flow link. """ - return StaticFlowLink(target=link_config) + return StaticFlowLink(link_config) def as_json(self) -> Text: """Returns the flow link as a dictionary. @@ -1436,3 +1436,8 @@ def steps_in_tree(self) -> Generator[FlowStep, None, None]: def child_steps(self) -> List[FlowStep]: """Returns the child steps of the flow link.""" return [] + + @property + def target(self) -> Optional[Text]: + """Returns the target of the flow link.""" + return self.target_id From 3acbbca5601d97fcc97d9d33c03f574b60522477 Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Wed, 25 Oct 2023 10:03:00 +0200 Subject: [PATCH 08/31] fixed bad import and trailing whitespace --- rasa/core/policies/flow_policy.py | 2 +- tests/core/flows/test_flow_schema.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rasa/core/policies/flow_policy.py b/rasa/core/policies/flow_policy.py index 29ac09a88eb9..e68d84880f63 100644 --- a/rasa/core/policies/flow_policy.py +++ b/rasa/core/policies/flow_policy.py @@ -63,7 +63,7 @@ CollectInformationFlowStep, StaticFlowLink, ) -from rasa.shared.core.flows.steps.internal import EndFlowStep +from rasa.shared.core.flows.flow import EndFlowStep from rasa.core.featurizers.tracker_featurizers import TrackerFeaturizer from rasa.core.policies.policy import Policy, PolicyPrediction from rasa.engine.graph import ExecutionContext diff --git a/tests/core/flows/test_flow_schema.py b/tests/core/flows/test_flow_schema.py index 0f1c4a1e9015..55a179326bf6 100644 --- a/tests/core/flows/test_flow_schema.py +++ b/tests/core/flows/test_flow_schema.py @@ -25,7 +25,7 @@ def test_schema_validation_fails_on_empty_steps_for_branch() -> None: - action: utter_greet next: - if: "status == logged_in" - then: + then: - action: utter_already_logged_in next: "END" - else: From 5d943e393780e8a9bdedfa651a361d38a915e306 Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Wed, 25 Oct 2023 14:42:40 +0200 Subject: [PATCH 09/31] Removed unused method --- rasa/shared/core/flows/flow.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/rasa/shared/core/flows/flow.py b/rasa/shared/core/flows/flow.py index 498b2728d078..00d4423e1c11 100644 --- a/rasa/shared/core/flows/flow.py +++ b/rasa/shared/core/flows/flow.py @@ -131,20 +131,6 @@ def flow_by_id(self, id: Optional[Text]) -> Optional[Flow]: else: return None - def step_by_id(self, step_id: Text, flow_id: Text) -> FlowStep: - """Return the step with the given id.""" - flow = self.flow_by_id(flow_id) - if not flow: - raise UnresolvedFlowException(flow_id) - - step = flow.step_by_id(step_id) - if not step: - raise UnresolvedFlowStepIdException( - step_id, flow.id, referenced_from_step_id=None - ) - - return step - def validate(self) -> None: """Validate the flows.""" for flow in self.underlying_flows: From 5b67da8dadf359de210128e687ea2829bc3805e4 Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Wed, 25 Oct 2023 14:47:46 +0200 Subject: [PATCH 10/31] Extracted FlowsList into own file --- rasa/core/policies/flow_policy.py | 2 +- rasa/core/processor.py | 2 +- .../commands/can_not_handle_command.py | 2 +- .../commands/cancel_flow_command.py | 2 +- .../commands/chit_chat_answer_command.py | 2 +- .../commands/clarify_command.py | 2 +- .../commands/command.py | 2 +- .../commands/correct_slots_command.py | 3 +- .../commands/error_command.py | 2 +- .../commands/handle_code_change_command.py | 2 +- .../commands/human_handoff_command.py | 2 +- .../commands/knowledge_answer_command.py | 2 +- .../commands/set_slot_command.py | 2 +- .../commands/start_flow_command.py | 2 +- .../generator/command_generator.py | 2 +- .../generator/llm_command_generator.py | 2 +- .../processor/command_processor.py | 2 +- .../processor/command_processor_component.py | 2 +- .../stack/frames/flow_stack_frame.py | 3 +- rasa/dialogue_understanding/stack/utils.py | 3 +- .../providers/flows_provider.py | 2 +- rasa/shared/core/flows/flow.py | 115 ----------------- rasa/shared/core/flows/flows_list.py | 120 ++++++++++++++++++ rasa/shared/core/flows/yaml_flows_io.py | 3 +- rasa/shared/importers/importer.py | 2 +- rasa/shared/importers/rasa.py | 2 +- rasa/shared/importers/utils.py | 2 +- rasa/validator.py | 2 +- tests/core/flows/test_flow.py | 3 +- tests/core/policies/test_flow_policy.py | 2 +- .../commands/conftest.py | 2 +- .../commands/test_command_processor.py | 2 +- .../test_handle_code_change_command.py | 2 +- .../commands/test_set_slot_command.py | 2 +- .../generator/test_command_generator.py | 2 +- .../generator/test_llm_command_generator.py | 2 +- .../stack/frames/test_flow_frame.py | 2 +- tests/utilities.py | 2 +- 38 files changed, 161 insertions(+), 151 deletions(-) create mode 100644 rasa/shared/core/flows/flows_list.py diff --git a/rasa/core/policies/flow_policy.py b/rasa/core/policies/flow_policy.py index e68d84880f63..8d5ec48567c8 100644 --- a/rasa/core/policies/flow_policy.py +++ b/rasa/core/policies/flow_policy.py @@ -52,7 +52,6 @@ ElseFlowLink, Flow, FlowStep, - FlowsList, GenerateResponseFlowStep, IfFlowLink, SlotRejection, @@ -63,6 +62,7 @@ CollectInformationFlowStep, StaticFlowLink, ) +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.flows.flow import EndFlowStep from rasa.core.featurizers.tracker_featurizers import TrackerFeaturizer from rasa.core.policies.policy import Policy, PolicyPrediction diff --git a/rasa/core/processor.py b/rasa/core/processor.py index 0100b9597399..9a65385bf867 100644 --- a/rasa/core/processor.py +++ b/rasa/core/processor.py @@ -17,7 +17,7 @@ from rasa.engine.storage.storage import ModelMetadata from rasa.model import get_latest_model from rasa.plugin import plugin_manager -from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.data import TrainingType import rasa.shared.utils.io import rasa.core.actions.action diff --git a/rasa/dialogue_understanding/commands/can_not_handle_command.py b/rasa/dialogue_understanding/commands/can_not_handle_command.py index 631cbe378ec4..8ed6d01bcc54 100644 --- a/rasa/dialogue_understanding/commands/can_not_handle_command.py +++ b/rasa/dialogue_understanding/commands/can_not_handle_command.py @@ -4,7 +4,7 @@ from typing import Any, Dict, List from rasa.dialogue_understanding.commands import Command from rasa.shared.core.events import Event -from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker diff --git a/rasa/dialogue_understanding/commands/cancel_flow_command.py b/rasa/dialogue_understanding/commands/cancel_flow_command.py index 904fdc775b82..5e9f368fbbce 100644 --- a/rasa/dialogue_understanding/commands/cancel_flow_command.py +++ b/rasa/dialogue_understanding/commands/cancel_flow_command.py @@ -10,7 +10,7 @@ from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack from rasa.dialogue_understanding.stack.frames import UserFlowStackFrame from rasa.shared.core.events import Event -from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker from rasa.dialogue_understanding.stack.utils import top_user_flow_frame diff --git a/rasa/dialogue_understanding/commands/chit_chat_answer_command.py b/rasa/dialogue_understanding/commands/chit_chat_answer_command.py index e8559e46c820..e26e07d46a22 100644 --- a/rasa/dialogue_understanding/commands/chit_chat_answer_command.py +++ b/rasa/dialogue_understanding/commands/chit_chat_answer_command.py @@ -6,7 +6,7 @@ from rasa.dialogue_understanding.patterns.chitchat import ChitchatPatternFlowStackFrame from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack from rasa.shared.core.events import Event -from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker diff --git a/rasa/dialogue_understanding/commands/clarify_command.py b/rasa/dialogue_understanding/commands/clarify_command.py index 21bbd9ec6f51..13d6c16c7e31 100644 --- a/rasa/dialogue_understanding/commands/clarify_command.py +++ b/rasa/dialogue_understanding/commands/clarify_command.py @@ -8,7 +8,7 @@ from rasa.dialogue_understanding.patterns.clarify import ClarifyPatternFlowStackFrame from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack from rasa.shared.core.events import Event -from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker structlogger = structlog.get_logger() diff --git a/rasa/dialogue_understanding/commands/command.py b/rasa/dialogue_understanding/commands/command.py index 23485c072d8f..e9220bba5809 100644 --- a/rasa/dialogue_understanding/commands/command.py +++ b/rasa/dialogue_understanding/commands/command.py @@ -4,7 +4,7 @@ import dataclasses from typing import Any, Dict, List from rasa.shared.core.events import Event -from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker import rasa.shared.utils.common diff --git a/rasa/dialogue_understanding/commands/correct_slots_command.py b/rasa/dialogue_understanding/commands/correct_slots_command.py index bc29b90b6a9f..09d7154985a0 100644 --- a/rasa/dialogue_understanding/commands/correct_slots_command.py +++ b/rasa/dialogue_understanding/commands/correct_slots_command.py @@ -16,7 +16,8 @@ UserFlowStackFrame, ) from rasa.shared.core.events import Event -from rasa.shared.core.flows.flow import END_STEP, ContinueFlowStep, FlowStep, FlowsList +from rasa.shared.core.flows.flow import END_STEP, ContinueFlowStep, FlowStep +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker import rasa.dialogue_understanding.stack.utils as utils diff --git a/rasa/dialogue_understanding/commands/error_command.py b/rasa/dialogue_understanding/commands/error_command.py index da5b3fbaf393..4b6c70b37266 100644 --- a/rasa/dialogue_understanding/commands/error_command.py +++ b/rasa/dialogue_understanding/commands/error_command.py @@ -10,7 +10,7 @@ ) from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack from rasa.shared.core.events import Event -from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker structlogger = structlog.get_logger() diff --git a/rasa/dialogue_understanding/commands/handle_code_change_command.py b/rasa/dialogue_understanding/commands/handle_code_change_command.py index c54bce685f17..26457895094c 100644 --- a/rasa/dialogue_understanding/commands/handle_code_change_command.py +++ b/rasa/dialogue_understanding/commands/handle_code_change_command.py @@ -10,7 +10,7 @@ from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack from rasa.shared.core.constants import DIALOGUE_STACK_SLOT from rasa.shared.core.events import Event, SlotSet -from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker from rasa.dialogue_understanding.stack.utils import top_user_flow_frame diff --git a/rasa/dialogue_understanding/commands/human_handoff_command.py b/rasa/dialogue_understanding/commands/human_handoff_command.py index a91630018c50..f8f6f7494a2c 100644 --- a/rasa/dialogue_understanding/commands/human_handoff_command.py +++ b/rasa/dialogue_understanding/commands/human_handoff_command.py @@ -4,7 +4,7 @@ from typing import Any, Dict, List from rasa.dialogue_understanding.commands import Command from rasa.shared.core.events import Event -from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker diff --git a/rasa/dialogue_understanding/commands/knowledge_answer_command.py b/rasa/dialogue_understanding/commands/knowledge_answer_command.py index bcac001b2c57..580e2682cba7 100644 --- a/rasa/dialogue_understanding/commands/knowledge_answer_command.py +++ b/rasa/dialogue_understanding/commands/knowledge_answer_command.py @@ -6,7 +6,7 @@ from rasa.dialogue_understanding.patterns.search import SearchPatternFlowStackFrame from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack from rasa.shared.core.events import Event -from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker diff --git a/rasa/dialogue_understanding/commands/set_slot_command.py b/rasa/dialogue_understanding/commands/set_slot_command.py index b9f9de59ca7b..88395803e97a 100644 --- a/rasa/dialogue_understanding/commands/set_slot_command.py +++ b/rasa/dialogue_understanding/commands/set_slot_command.py @@ -8,7 +8,7 @@ from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack from rasa.dialogue_understanding.stack.utils import filled_slots_for_active_flow from rasa.shared.core.events import Event, SlotSet -from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker structlogger = structlog.get_logger() diff --git a/rasa/dialogue_understanding/commands/start_flow_command.py b/rasa/dialogue_understanding/commands/start_flow_command.py index 3604d46afff5..d7b129cc1c93 100644 --- a/rasa/dialogue_understanding/commands/start_flow_command.py +++ b/rasa/dialogue_understanding/commands/start_flow_command.py @@ -15,7 +15,7 @@ user_flows_on_the_stack, ) from rasa.shared.core.events import Event -from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker structlogger = structlog.get_logger() diff --git a/rasa/dialogue_understanding/generator/command_generator.py b/rasa/dialogue_understanding/generator/command_generator.py index 44dee5058c94..40269f1fa449 100644 --- a/rasa/dialogue_understanding/generator/command_generator.py +++ b/rasa/dialogue_understanding/generator/command_generator.py @@ -1,7 +1,7 @@ from typing import List, Optional import structlog from rasa.dialogue_understanding.commands import Command -from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker from rasa.shared.nlu.training_data.message import Message from rasa.shared.nlu.constants import COMMANDS diff --git a/rasa/dialogue_understanding/generator/llm_command_generator.py b/rasa/dialogue_understanding/generator/llm_command_generator.py index aad8e01e90d0..674400186aa7 100644 --- a/rasa/dialogue_understanding/generator/llm_command_generator.py +++ b/rasa/dialogue_understanding/generator/llm_command_generator.py @@ -26,9 +26,9 @@ from rasa.shared.core.flows.flow import ( Flow, FlowStep, - FlowsList, CollectInformationFlowStep, ) +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker from rasa.shared.core.slots import ( BooleanSlot, diff --git a/rasa/dialogue_understanding/processor/command_processor.py b/rasa/dialogue_understanding/processor/command_processor.py index e80a9c0998b2..08fa42dadd6d 100644 --- a/rasa/dialogue_understanding/processor/command_processor.py +++ b/rasa/dialogue_understanding/processor/command_processor.py @@ -29,9 +29,9 @@ from rasa.shared.core.constants import FLOW_HASHES_SLOT from rasa.shared.core.events import Event, SlotSet from rasa.shared.core.flows.flow import ( - FlowsList, CollectInformationFlowStep, ) +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker from rasa.shared.nlu.constants import COMMANDS diff --git a/rasa/dialogue_understanding/processor/command_processor_component.py b/rasa/dialogue_understanding/processor/command_processor_component.py index e557ab2c83c8..369da682ccf6 100644 --- a/rasa/dialogue_understanding/processor/command_processor_component.py +++ b/rasa/dialogue_understanding/processor/command_processor_component.py @@ -7,7 +7,7 @@ from rasa.engine.storage.resource import Resource from rasa.engine.storage.storage import ModelStorage from rasa.shared.core.events import Event -from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker diff --git a/rasa/dialogue_understanding/stack/frames/flow_stack_frame.py b/rasa/dialogue_understanding/stack/frames/flow_stack_frame.py index 20b7cfc6b4be..d611006f74b9 100644 --- a/rasa/dialogue_understanding/stack/frames/flow_stack_frame.py +++ b/rasa/dialogue_understanding/stack/frames/flow_stack_frame.py @@ -4,7 +4,8 @@ from typing import Any, Dict, Optional from rasa.dialogue_understanding.stack.frames import DialogueStackFrame -from rasa.shared.core.flows.flow import START_STEP, Flow, FlowStep, FlowsList +from rasa.shared.core.flows.flow import START_STEP, Flow, FlowStep +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.exceptions import RasaException diff --git a/rasa/dialogue_understanding/stack/utils.py b/rasa/dialogue_understanding/stack/utils.py index 71e59b90d4ba..955e9c5acd62 100644 --- a/rasa/dialogue_understanding/stack/utils.py +++ b/rasa/dialogue_understanding/stack/utils.py @@ -5,7 +5,8 @@ from rasa.dialogue_understanding.stack.frames import BaseFlowStackFrame from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack from rasa.dialogue_understanding.stack.frames import UserFlowStackFrame -from rasa.shared.core.flows.flow import END_STEP, ContinueFlowStep, FlowsList +from rasa.shared.core.flows.flow import END_STEP, ContinueFlowStep +from rasa.shared.core.flows.flows_list import FlowsList def top_flow_frame( diff --git a/rasa/graph_components/providers/flows_provider.py b/rasa/graph_components/providers/flows_provider.py index 777f676b992e..e19384860e07 100644 --- a/rasa/graph_components/providers/flows_provider.py +++ b/rasa/graph_components/providers/flows_provider.py @@ -8,7 +8,7 @@ from rasa.shared.importers.importer import TrainingDataImporter from rasa.shared.core.flows.yaml_flows_io import YAMLFlowsReader, YamlFlowsWriter -from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.flows_list import FlowsList FLOWS_PERSISTENCE_FILE_NAME = "flows.yml" diff --git a/rasa/shared/core/flows/flow.py b/rasa/shared/core/flows/flow.py index 00d4423e1c11..1bd7fbafab3f 100644 --- a/rasa/shared/core/flows/flow.py +++ b/rasa/shared/core/flows/flow.py @@ -23,7 +23,6 @@ MissingElseBranchException, NoNextAllowedForLinkException, UnresolvedFlowStepIdException, - UnresolvedFlowException, EmptyStepSequenceException, EmptyFlowException, ) @@ -46,120 +45,6 @@ DEFAULT_STEPS = {END_STEP, START_STEP} -class FlowsList: - """Represents the configuration of a list of flow. - - We need this class to be able to fingerprint the flows configuration. - Fingerprinting is needed to make sure that the model is retrained if the - flows configuration changes. - """ - - def __init__(self, flows: List[Flow]) -> None: - """Initializes the configuration of flows. - - Args: - flows: The flows to be configured. - """ - self.underlying_flows = flows - - def __iter__(self) -> Generator[Flow, None, None]: - """Iterates over the flows.""" - yield from self.underlying_flows - - def __eq__(self, other: Any) -> bool: - """Compares the flows.""" - return ( - isinstance(other, FlowsList) - and self.underlying_flows == other.underlying_flows - ) - - def is_empty(self) -> bool: - """Returns whether the flows list is empty.""" - return len(self.underlying_flows) == 0 - - @classmethod - def from_json( - cls, flows_configs: Optional[Dict[Text, Dict[Text, Any]]] - ) -> FlowsList: - """Used to read flows from parsed YAML. - - Args: - flows_configs: The parsed YAML as a dictionary. - - Returns: - The parsed flows. - """ - if not flows_configs: - return cls([]) - - return cls( - [ - Flow.from_json(flow_id, flow_config) - for flow_id, flow_config in flows_configs.items() - ] - ) - - def as_json(self) -> List[Dict[Text, Any]]: - """Returns the flows as a dictionary. - - Returns: - The flows as a dictionary. - """ - return [flow.as_json() for flow in self.underlying_flows] - - def fingerprint(self) -> str: - """Creates a fingerprint of the flows configuration. - - Returns: - The fingerprint of the flows configuration. - """ - flow_dicts = [flow.as_json() for flow in self.underlying_flows] - return rasa.shared.utils.io.get_list_fingerprint(flow_dicts) - - def merge(self, other: FlowsList) -> FlowsList: - """Merges two lists of flows together.""" - return FlowsList(self.underlying_flows + other.underlying_flows) - - def flow_by_id(self, id: Optional[Text]) -> Optional[Flow]: - """Return the flow with the given id.""" - if not id: - return None - - for flow in self.underlying_flows: - if flow.id == id: - return flow - else: - return None - - def validate(self) -> None: - """Validate the flows.""" - for flow in self.underlying_flows: - flow.validate() - - @property - def user_flow_ids(self) -> List[str]: - """Get all ids of flows that can be started by a user. - - Returns: - The ids of all flows that can be started by a user.""" - return [f.id for f in self.user_flows] - - @property - def user_flows(self) -> FlowsList: - """Get all flows that can be started by a user. - - Returns: - All flows that can be started by a user.""" - return FlowsList( - [f for f in self.underlying_flows if not f.is_rasa_default_flow] - ) - - @property - def utterances(self) -> Set[str]: - """Retrieve all utterances of all flows""" - return set().union(*[flow.utterances for flow in self.underlying_flows]) - - @dataclass class Flow: """Represents the configuration of a flow.""" diff --git a/rasa/shared/core/flows/flows_list.py b/rasa/shared/core/flows/flows_list.py new file mode 100644 index 000000000000..c0c0ceebd1c1 --- /dev/null +++ b/rasa/shared/core/flows/flows_list.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from typing import List, Generator, Any, Optional, Dict, Text, Set + +import rasa.shared +from rasa.shared.core.flows.flow import Flow + + +class FlowsList: + """Represents the configuration of a list of flow. + + We need this class to be able to fingerprint the flows configuration. + Fingerprinting is needed to make sure that the model is retrained if the + flows configuration changes. + """ + + def __init__(self, flows: List[Flow]) -> None: + """Initializes the configuration of flows. + + Args: + flows: The flows to be configured. + """ + self.underlying_flows = flows + + def __iter__(self) -> Generator[Flow, None, None]: + """Iterates over the flows.""" + yield from self.underlying_flows + + def __eq__(self, other: Any) -> bool: + """Compares the flows.""" + return ( + isinstance(other, FlowsList) + and self.underlying_flows == other.underlying_flows + ) + + def is_empty(self) -> bool: + """Returns whether the flows list is empty.""" + return len(self.underlying_flows) == 0 + + @classmethod + def from_json( + cls, flows_configs: Optional[Dict[Text, Dict[Text, Any]]] + ) -> FlowsList: + """Used to read flows from parsed YAML. + + Args: + flows_configs: The parsed YAML as a dictionary. + + Returns: + The parsed flows. + """ + if not flows_configs: + return cls([]) + + return cls( + [ + Flow.from_json(flow_id, flow_config) + for flow_id, flow_config in flows_configs.items() + ] + ) + + def as_json(self) -> List[Dict[Text, Any]]: + """Returns the flows as a dictionary. + + Returns: + The flows as a dictionary. + """ + return [flow.as_json() for flow in self.underlying_flows] + + def fingerprint(self) -> str: + """Creates a fingerprint of the flows configuration. + + Returns: + The fingerprint of the flows configuration. + """ + flow_dicts = [flow.as_json() for flow in self.underlying_flows] + return rasa.shared.utils.io.get_list_fingerprint(flow_dicts) + + def merge(self, other: FlowsList) -> FlowsList: + """Merges two lists of flows together.""" + return FlowsList(self.underlying_flows + other.underlying_flows) + + def flow_by_id(self, id: Optional[Text]) -> Optional[Flow]: + """Return the flow with the given id.""" + if not id: + return None + + for flow in self.underlying_flows: + if flow.id == id: + return flow + else: + return None + + def validate(self) -> None: + """Validate the flows.""" + for flow in self.underlying_flows: + flow.validate() + + @property + def user_flow_ids(self) -> List[str]: + """Get all ids of flows that can be started by a user. + + Returns: + The ids of all flows that can be started by a user.""" + return [f.id for f in self.user_flows] + + @property + def user_flows(self) -> FlowsList: + """Get all flows that can be started by a user. + + Returns: + All flows that can be started by a user.""" + return FlowsList( + [f for f in self.underlying_flows if not f.is_rasa_default_flow] + ) + + @property + def utterances(self) -> Set[str]: + """Retrieve all utterances of all flows""" + return set().union(*[flow.utterances for flow in self.underlying_flows]) diff --git a/rasa/shared/core/flows/yaml_flows_io.py b/rasa/shared/core/flows/yaml_flows_io.py index 14cf7da82557..21cf8e5a335d 100644 --- a/rasa/shared/core/flows/yaml_flows_io.py +++ b/rasa/shared/core/flows/yaml_flows_io.py @@ -8,7 +8,8 @@ import rasa.shared.utils.validation from rasa.shared.exceptions import YamlException -from rasa.shared.core.flows.flow import Flow, FlowsList +from rasa.shared.core.flows.flow import Flow +from rasa.shared.core.flows.flows_list import FlowsList FLOWS_SCHEMA_FILE = "shared/core/flows/flows_yaml_schema.json" diff --git a/rasa/shared/importers/importer.py b/rasa/shared/importers/importer.py index 9cd5904d43ee..eab949c5fe87 100644 --- a/rasa/shared/importers/importer.py +++ b/rasa/shared/importers/importer.py @@ -6,7 +6,7 @@ import pkg_resources import rasa.shared.constants -from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.flows_list import FlowsList import rasa.shared.utils.common import rasa.shared.core.constants import rasa.shared.utils.io diff --git a/rasa/shared/importers/rasa.py b/rasa/shared/importers/rasa.py index 355f913aedd4..cbf3a896cf03 100644 --- a/rasa/shared/importers/rasa.py +++ b/rasa/shared/importers/rasa.py @@ -1,7 +1,7 @@ import logging import os from typing import Dict, List, Optional, Text, Union -from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.flows_list import FlowsList import rasa.shared.data import rasa.shared.utils.common diff --git a/rasa/shared/importers/utils.py b/rasa/shared/importers/utils.py index 019363ba77d6..5a2ade83568d 100644 --- a/rasa/shared/importers/utils.py +++ b/rasa/shared/importers/utils.py @@ -1,7 +1,7 @@ from typing import Iterable, Text, Optional, List from rasa.shared.core.domain import Domain -from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.training_data.structures import StoryGraph from rasa.shared.nlu.training_data.training_data import TrainingData diff --git a/rasa/validator.py b/rasa/validator.py index 285afdf640c7..b773c8c45b62 100644 --- a/rasa/validator.py +++ b/rasa/validator.py @@ -11,10 +11,10 @@ ActionFlowStep, BranchFlowStep, CollectInformationFlowStep, - FlowsList, IfFlowLink, SetSlotsFlowStep, ) +from rasa.shared.core.flows.flows_list import FlowsList import rasa.shared.nlu.constants from rasa.shared.constants import ( ASSISTANT_ID_DEFAULT_VALUE, diff --git a/tests/core/flows/test_flow.py b/tests/core/flows/test_flow.py index 259363c73324..f2f455e3d6d9 100644 --- a/tests/core/flows/test_flow.py +++ b/tests/core/flows/test_flow.py @@ -4,7 +4,8 @@ EmptyStepSequenceException, EmptyFlowException, ) -from rasa.shared.core.flows.flow import Flow, FlowsList +from rasa.shared.core.flows.flow import Flow +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.importers.importer import FlowSyncImporter from tests.utilities import flows_from_str diff --git a/tests/core/policies/test_flow_policy.py b/tests/core/policies/test_flow_policy.py index 5a84a9dcca1c..c33b388c3e9b 100644 --- a/tests/core/policies/test_flow_policy.py +++ b/tests/core/policies/test_flow_policy.py @@ -14,7 +14,7 @@ from rasa.engine.storage.storage import ModelStorage from rasa.shared.core.domain import Domain from rasa.shared.core.events import ActionExecuted, Event, SlotSet -from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.flows.yaml_flows_io import YAMLFlowsReader from rasa.shared.core.slots import TextSlot from rasa.shared.core.trackers import DialogueStateTracker diff --git a/tests/dialogue_understanding/commands/conftest.py b/tests/dialogue_understanding/commands/conftest.py index 7741b820865b..3322ab012231 100644 --- a/tests/dialogue_understanding/commands/conftest.py +++ b/tests/dialogue_understanding/commands/conftest.py @@ -3,7 +3,7 @@ from rasa.dialogue_understanding.commands import StartFlowCommand from rasa.dialogue_understanding.processor.command_processor import execute_commands from rasa.shared.core.events import UserUttered -from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker from rasa.shared.nlu.constants import COMMANDS from rasa.shared.core.flows.yaml_flows_io import flows_from_str diff --git a/tests/dialogue_understanding/commands/test_command_processor.py b/tests/dialogue_understanding/commands/test_command_processor.py index 0e623c57c2a4..b93b200fbc41 100644 --- a/tests/dialogue_understanding/commands/test_command_processor.py +++ b/tests/dialogue_understanding/commands/test_command_processor.py @@ -11,7 +11,7 @@ PatternFlowStackFrame, ) from rasa.shared.core.constants import FLOW_HASHES_SLOT -from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker from rasa.shared.core.flows.yaml_flows_io import flows_from_str from tests.dialogue_understanding.commands.conftest import start_bar_user_uttered diff --git a/tests/dialogue_understanding/commands/test_handle_code_change_command.py b/tests/dialogue_understanding/commands/test_handle_code_change_command.py index c1868d55f47c..5fa9eda2044b 100644 --- a/tests/dialogue_understanding/commands/test_handle_code_change_command.py +++ b/tests/dialogue_understanding/commands/test_handle_code_change_command.py @@ -17,11 +17,11 @@ from rasa.shared.core.domain import Domain from rasa.shared.core.events import SlotSet from rasa.shared.core.flows.flow import ( - FlowsList, START_STEP, ContinueFlowStep, END_STEP, ) +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker from tests.dialogue_understanding.commands.test_command_processor import ( start_bar_user_uttered, diff --git a/tests/dialogue_understanding/commands/test_set_slot_command.py b/tests/dialogue_understanding/commands/test_set_slot_command.py index 3b04c5c6dc88..597b510b9ce5 100644 --- a/tests/dialogue_understanding/commands/test_set_slot_command.py +++ b/tests/dialogue_understanding/commands/test_set_slot_command.py @@ -2,7 +2,7 @@ from rasa.dialogue_understanding.commands.set_slot_command import SetSlotCommand from rasa.shared.core.constants import DIALOGUE_STACK_SLOT from rasa.shared.core.events import SlotSet -from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker from rasa.shared.core.flows.yaml_flows_io import flows_from_str diff --git a/tests/dialogue_understanding/generator/test_command_generator.py b/tests/dialogue_understanding/generator/test_command_generator.py index 8a5ea3a5c394..30ce6aa76ca2 100644 --- a/tests/dialogue_understanding/generator/test_command_generator.py +++ b/tests/dialogue_understanding/generator/test_command_generator.py @@ -7,7 +7,7 @@ from rasa.dialogue_understanding.commands.chit_chat_answer_command import ( ChitChatAnswerCommand, ) -from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker from rasa.shared.nlu.constants import TEXT, COMMANDS from rasa.shared.nlu.training_data.message import Message diff --git a/tests/dialogue_understanding/generator/test_llm_command_generator.py b/tests/dialogue_understanding/generator/test_llm_command_generator.py index 56c50fdcf8dd..887bfb049a8e 100644 --- a/tests/dialogue_understanding/generator/test_llm_command_generator.py +++ b/tests/dialogue_understanding/generator/test_llm_command_generator.py @@ -28,9 +28,9 @@ from rasa.shared.core.events import BotUttered, SlotSet, UserUttered from rasa.shared.core.flows.flow import ( CollectInformationFlowStep, - FlowsList, SlotRejection, ) +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.slots import ( Slot, BooleanSlot, diff --git a/tests/dialogue_understanding/stack/frames/test_flow_frame.py b/tests/dialogue_understanding/stack/frames/test_flow_frame.py index b07940fa8e77..db3a9fdda842 100644 --- a/tests/dialogue_understanding/stack/frames/test_flow_frame.py +++ b/tests/dialogue_understanding/stack/frames/test_flow_frame.py @@ -10,9 +10,9 @@ ActionFlowStep, Flow, FlowLinks, - FlowsList, StepSequence, ) +from rasa.shared.core.flows.flows_list import FlowsList def test_flow_frame_type(): diff --git a/tests/utilities.py b/tests/utilities.py index 0874918d7251..cfed026c5165 100644 --- a/tests/utilities.py +++ b/tests/utilities.py @@ -1,6 +1,6 @@ from yarl import URL from rasa.shared.core.domain import Domain -from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.flows.yaml_flows_io import flows_from_str from rasa.shared.importers.importer import FlowSyncImporter From c9c87162e8e5084a3a4c1db595dc839a954cd12d Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Wed, 25 Oct 2023 15:00:00 +0200 Subject: [PATCH 11/31] small doc string improvement, removed unused methods --- rasa/shared/core/flows/flow.py | 26 +++----------------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/rasa/shared/core/flows/flow.py b/rasa/shared/core/flows/flow.py index 1bd7fbafab3f..6361d8248bb8 100644 --- a/rasa/shared/core/flows/flow.py +++ b/rasa/shared/core/flows/flow.py @@ -255,9 +255,9 @@ def first_step_in_flow(self) -> Optional[FlowStep]: def previous_collect_steps( self, step_id: Optional[str] ) -> List[CollectInformationFlowStep]: - """Returns the collect informations asked before the given step. + """Returns the collect information steps asked before the given step. - CollectInformations are returned roughly in reverse order, i.e. the first + CollectInformationSteps are returned roughly in reverse order, i.e. the first collect information in the list is the one asked last. But due to circles in the flow the order is not guaranteed to be exactly reverse. """ @@ -265,7 +265,7 @@ def previous_collect_steps( def _previously_asked_collect( current_step_id: str, visited_steps: Set[str] ) -> List[CollectInformationFlowStep]: - """Returns the collect informations asked before the given step. + """Returns the collect information steps asked before the given step. Keeps track of the steps that have been visited to avoid circles. """ @@ -294,26 +294,6 @@ def _previously_asked_collect( return _previously_asked_collect(step_id or START_STEP, set()) - def get_trigger_intents(self) -> Set[str]: - """Returns the trigger intents of the flow""" - results: Set[str] = set() - if len(self.steps) == 0: - return results - - first_step = self.steps[0] - - if not isinstance(first_step, UserMessageStep): - return results - - for condition in first_step.trigger_conditions: - results.add(condition.intent) - - return results - - def is_user_triggerable(self) -> bool: - """Test whether a user can trigger the flow with an intent.""" - return len(self.get_trigger_intents()) > 0 - @property def is_rasa_default_flow(self) -> bool: """Test whether something is a rasa default flow.""" From 9ffd16133e648c95a199934817f396da57da973a Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Wed, 25 Oct 2023 15:18:40 +0200 Subject: [PATCH 12/31] Moved flow validation outside of class --- rasa/shared/core/flows/flow.py | 87 -------------------- rasa/shared/core/flows/flows_list.py | 3 +- rasa/shared/core/flows/validation.py | 117 +++++++++++++++++++++++++++ tests/core/flows/test_flow.py | 5 +- 4 files changed, 122 insertions(+), 90 deletions(-) create mode 100644 rasa/shared/core/flows/validation.py diff --git a/rasa/shared/core/flows/flow.py b/rasa/shared/core/flows/flow.py index 6361d8248bb8..e8628eff73db 100644 --- a/rasa/shared/core/flows/flow.py +++ b/rasa/shared/core/flows/flow.py @@ -138,93 +138,6 @@ def readable_name(self) -> str: """Returns the name of the flow or its id if no name is set.""" return self.name or self.id - def validate(self) -> None: - """Validates the flow configuration. - - This ensures that the flow semantically makes sense. E.g. it - checks: - - whether all next links point to existing steps - - whether all steps can be reached from the start step - """ - self._validate_no_empty_flows() - self._validate_no_empty_step_sequences() - self._validate_all_steps_next_property() - self._validate_all_next_ids_are_available_steps() - self._validate_all_steps_can_be_reached() - self._validate_all_branches_have_an_else() - self._validate_not_using_buildin_ids() - - def _validate_no_empty_flows(self) -> None: - """Validate that the flow is not empty.""" - if len(self.steps) == 0: - raise EmptyFlowException(self.id) - - def _validate_no_empty_step_sequences(self) -> None: - """Validate that the flow does not have any empty step sequences.""" - for step in self.steps: - for link in step.next.links: - if isinstance(link, BranchBasedLink) and link.target is None: - raise EmptyStepSequenceException(self.id, step.id) - - def _validate_not_using_buildin_ids(self) -> None: - """Validates that the flow does not use any of the build in ids.""" - for step in self.steps: - if step.id in DEFAULT_STEPS or step.id.startswith(CONTINUE_STEP_PREFIX): - raise ReservedFlowStepIdException(step.id, self.id) - - def _validate_all_branches_have_an_else(self) -> None: - """Validates that all branches have an else link.""" - for step in self.steps: - links = step.next.links - - has_an_if = any(isinstance(link, IfFlowLink) for link in links) - has_an_else = any(isinstance(link, ElseFlowLink) for link in links) - - if has_an_if and not has_an_else: - raise MissingElseBranchException(step.id, self.id) - - def _validate_all_steps_next_property(self) -> None: - """Validates that every step has a next link.""" - for step in self.steps: - if isinstance(step, LinkFlowStep): - # link steps can't have a next link! - if not step.next.no_link_available(): - raise NoNextAllowedForLinkException(step.id, self.id) - elif step.next.no_link_available(): - # all other steps should have a next link - raise MissingNextLinkException(step.id, self.id) - - def _validate_all_next_ids_are_available_steps(self) -> None: - """Validates that all next links point to existing steps.""" - available_steps = {step.id for step in self.steps} | DEFAULT_STEPS - for step in self.steps: - for link in step.next.links: - if link.target not in available_steps: - raise UnresolvedFlowStepIdException(link.target, self.id, step.id) - - def _validate_all_steps_can_be_reached(self) -> None: - """Validates that all steps can be reached from the start step.""" - - def _reachable_steps( - step: Optional[FlowStep], reached_steps: Set[Text] - ) -> Set[Text]: - """Validates that the given step can be reached from the start step.""" - if step is None or step.id in reached_steps: - return reached_steps - - reached_steps.add(step.id) - for link in step.next.links: - reached_steps = _reachable_steps( - self.step_by_id(link.target), reached_steps - ) - return reached_steps - - reached_steps = _reachable_steps(self.first_step_in_flow(), set()) - - for step in self.steps: - if step.id not in reached_steps: - raise UnreachableFlowStepException(step.id, self.id) - def step_by_id(self, step_id: Optional[Text]) -> Optional[FlowStep]: """Returns the step with the given id.""" if not step_id: diff --git a/rasa/shared/core/flows/flows_list.py b/rasa/shared/core/flows/flows_list.py index c0c0ceebd1c1..c57cbd879489 100644 --- a/rasa/shared/core/flows/flows_list.py +++ b/rasa/shared/core/flows/flows_list.py @@ -4,6 +4,7 @@ import rasa.shared from rasa.shared.core.flows.flow import Flow +from rasa.shared.core.flows.validation import validate_flow class FlowsList: @@ -94,7 +95,7 @@ def flow_by_id(self, id: Optional[Text]) -> Optional[Flow]: def validate(self) -> None: """Validate the flows.""" for flow in self.underlying_flows: - flow.validate() + validate_flow(flow) @property def user_flow_ids(self) -> List[str]: diff --git a/rasa/shared/core/flows/validation.py b/rasa/shared/core/flows/validation.py new file mode 100644 index 000000000000..63746f6e4619 --- /dev/null +++ b/rasa/shared/core/flows/validation.py @@ -0,0 +1,117 @@ +from typing import Optional, Set, Text + +from rasa.shared.core.flows.exceptions import ( + EmptyFlowException, + EmptyStepSequenceException, + ReservedFlowStepIdException, + MissingElseBranchException, + NoNextAllowedForLinkException, + MissingNextLinkException, + UnresolvedFlowStepIdException, + UnreachableFlowStepException, +) +from rasa.shared.core.flows.flow import ( + Flow, + BranchBasedLink, + DEFAULT_STEPS, + CONTINUE_STEP_PREFIX, + IfFlowLink, + ElseFlowLink, + LinkFlowStep, + FlowStep, +) + + +def validate_flow(flow: Flow) -> None: + """Validates the flow configuration. + + This ensures that the flow semantically makes sense. E.g. it + checks: + - whether all next links point to existing steps + - whether all steps can be reached from the start step + """ + validate_flow_not_empty(flow) + validate_no_empty_step_sequences(flow) + validate_all_steps_next_property(flow) + validate_all_next_ids_are_available_steps(flow) + validate_all_steps_can_be_reached(flow) + validate_all_branches_have_an_else(flow) + validate_not_using_buildin_ids(flow) + + +def validate_flow_not_empty(flow: Flow) -> None: + """Validate that the flow is not empty.""" + if len(flow.steps) == 0: + raise EmptyFlowException(flow.id) + + +def validate_no_empty_step_sequences(flow: Flow) -> None: + """Validate that the flow does not have any empty step sequences.""" + for step in flow.steps: + for link in step.next.links: + if isinstance(link, BranchBasedLink) and link.target is None: + raise EmptyStepSequenceException(flow.id, step.id) + + +def validate_not_using_buildin_ids(flow: Flow) -> None: + """Validates that the flow does not use any of the build in ids.""" + for step in flow.steps: + if step.id in DEFAULT_STEPS or step.id.startswith(CONTINUE_STEP_PREFIX): + raise ReservedFlowStepIdException(step.id, flow.id) + + +def validate_all_branches_have_an_else(flow: Flow) -> None: + """Validates that all branches have an else link.""" + for step in flow.steps: + links = step.next.links + + has_an_if = any(isinstance(link, IfFlowLink) for link in links) + has_an_else = any(isinstance(link, ElseFlowLink) for link in links) + + if has_an_if and not has_an_else: + raise MissingElseBranchException(step.id, flow.id) + + +def validate_all_steps_next_property(flow: Flow) -> None: + """Validates that every step has a next link.""" + for step in flow.steps: + if isinstance(step, LinkFlowStep): + # link steps can't have a next link! + if not step.next.no_link_available(): + raise NoNextAllowedForLinkException(step.id, flow.id) + elif step.next.no_link_available(): + # all other steps should have a next link + raise MissingNextLinkException(step.id, flow.id) + + +def validate_all_next_ids_are_available_steps(flow) -> None: + """Validates that all next links point to existing steps.""" + available_steps = {step.id for step in flow.steps} | DEFAULT_STEPS + for step in flow.steps: + for link in step.next.links: + if link.target not in available_steps: + raise UnresolvedFlowStepIdException(link.target, flow.id, step.id) + + +def validate_all_steps_can_be_reached(flow) -> None: + """Validates that all steps can be reached from the start step.""" + + def _reachable_steps( + step: Optional[FlowStep], reached_steps: Set[Text] + ) -> Set[Text]: + """Validates that the given step can be reached from the start step.""" + if step is None or step.id in reached_steps: + return reached_steps + + reached_steps.add(step.id) + for link in step.next.links: + reached_steps = _reachable_steps( + flow.step_by_id(link.target), reached_steps + ) + return reached_steps + + reached_steps = _reachable_steps(flow.first_step_in_flow(), set()) + + for step in flow.steps: + if step.id not in reached_steps: + raise UnreachableFlowStepException(step.id, flow.id) diff --git a/tests/core/flows/test_flow.py b/tests/core/flows/test_flow.py index f2f455e3d6d9..684a1c5fce5a 100644 --- a/tests/core/flows/test_flow.py +++ b/tests/core/flows/test_flow.py @@ -6,6 +6,7 @@ ) from rasa.shared.core.flows.flow import Flow from rasa.shared.core.flows.flows_list import FlowsList +from rasa.shared.core.flows.validation import validate_flow from rasa.shared.importers.importer import FlowSyncImporter from tests.utilities import flows_from_str @@ -116,7 +117,7 @@ def test_flow_from_json_with_empty_steps_raises(): flow_as_dict = {"description": "a flow with empty steps", "steps": []} flow = Flow.from_json("empty_flow", flow_as_dict) with pytest.raises(EmptyFlowException) as e: - flow.validate() + validate_flow(flow) assert e.value.flow_id == "empty_flow" @@ -132,6 +133,6 @@ def test_flow_from_json_with_empty_branch_raises(): } flow = Flow.from_json("empty_branch_flow", flow_as_dict) with pytest.raises(EmptyStepSequenceException) as e: - flow.validate() + validate_flow(flow) assert e.value.flow_id == "empty_branch_flow" assert "utter_something" in e.value.step_id From 20a67f31ad7ab9a9cd763e1fa4e3f0fbf4db8cbd Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Wed, 25 Oct 2023 15:31:07 +0200 Subject: [PATCH 13/31] improved typing --- rasa/shared/core/flows/flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rasa/shared/core/flows/flow.py b/rasa/shared/core/flows/flow.py index e8628eff73db..dd9eaba24856 100644 --- a/rasa/shared/core/flows/flow.py +++ b/rasa/shared/core/flows/flow.py @@ -956,7 +956,7 @@ class FlowLinks: links: List[FlowLink] @staticmethod - def from_json(flow_links_config: List[Dict[Text, Any]]) -> FlowLinks: + def from_json(flow_links_config: Union[str, List[Dict[Text, Any]]]) -> FlowLinks: """Used to read flow links from parsed YAML. Args: @@ -996,7 +996,7 @@ def link_from_json(link_config: Dict[Text, Any]) -> FlowLink: else: raise Exception("Invalid flow link") - def as_json(self) -> Any: + def as_json(self) -> Optional[Union[str, List[Dict[str, Any]]]]: """Returns the flow links as a dictionary. Returns: From 27d69fdc7ba5effa916429a2089a538f499bbb0f Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Wed, 25 Oct 2023 15:42:31 +0200 Subject: [PATCH 14/31] Moved branch based link from json to that class --- rasa/shared/core/flows/flow.py | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/rasa/shared/core/flows/flow.py b/rasa/shared/core/flows/flow.py index dd9eaba24856..cd5ced35519d 100644 --- a/rasa/shared/core/flows/flow.py +++ b/rasa/shared/core/flows/flow.py @@ -973,29 +973,12 @@ def from_json(flow_links_config: Union[str, List[Dict[Text, Any]]]) -> FlowLinks return FlowLinks( links=[ - FlowLinks.link_from_json(link_config) + BranchBasedLink.from_json(link_config) for link_config in flow_links_config if link_config ] ) - @staticmethod - def link_from_json(link_config: Dict[Text, Any]) -> FlowLink: - """Used to read a single flow links from parsed YAML. - - Args: - link_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow link. - """ - if "if" in link_config: - return IfFlowLink.from_json(link_config) - elif "else" in link_config: - return ElseFlowLink.from_json(link_config) - else: - raise Exception("Invalid flow link") - def as_json(self) -> Optional[Union[str, List[Dict[str, Any]]]]: """Returns the flow links as a dictionary. @@ -1089,6 +1072,21 @@ def target(self) -> Optional[Text]: else: return self.target_reference + @staticmethod + def from_json(link_config: Dict[Text, Any]) -> BranchBasedLink: + """Used to read a single flow links from parsed YAML. + + Args: + link_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow link. + """ + if "if" in link_config: + return IfFlowLink.from_json(link_config) + else: + return ElseFlowLink.from_json(link_config) + @dataclass class IfFlowLink(BranchBasedLink): From d2c428d37fde14f55f4565d260b3bd68c750aa31 Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Wed, 25 Oct 2023 17:00:36 +0200 Subject: [PATCH 15/31] Moved flow steps, step sequence and step links out of the main flow file --- rasa/core/actions/action_clean_stack.py | 2 +- rasa/core/policies/flow_policy.py | 6 +- .../commands/correct_slots_command.py | 2 +- .../generator/llm_command_generator.py | 4 +- .../dialogue_understanding/patterns/cancel.py | 2 +- .../patterns/collect_information.py | 2 +- .../patterns/correction.py | 4 +- .../processor/command_processor.py | 2 +- .../stack/frames/flow_stack_frame.py | 3 +- rasa/dialogue_understanding/stack/utils.py | 2 +- rasa/shared/core/flows/flow.py | 1025 +---------------- rasa/shared/core/flows/flow_step.py | 1002 ++++++++++++++++ rasa/shared/core/flows/validation.py | 4 +- rasa/validator.py | 2 +- .../test_handle_code_change_command.py | 2 +- .../generator/test_llm_command_generator.py | 2 +- .../stack/frames/test_flow_frame.py | 4 +- 17 files changed, 1040 insertions(+), 1030 deletions(-) create mode 100644 rasa/shared/core/flows/flow_step.py diff --git a/rasa/core/actions/action_clean_stack.py b/rasa/core/actions/action_clean_stack.py index a885abbdbf8e..edc3044b4b6a 100644 --- a/rasa/core/actions/action_clean_stack.py +++ b/rasa/core/actions/action_clean_stack.py @@ -14,7 +14,7 @@ from rasa.shared.core.constants import ACTION_CLEAN_STACK, DIALOGUE_STACK_SLOT from rasa.shared.core.domain import Domain from rasa.shared.core.events import Event, SlotSet -from rasa.shared.core.flows.flow import ContinueFlowStep, END_STEP +from rasa.shared.core.flows.flow_step import ContinueFlowStep, END_STEP from rasa.shared.core.trackers import DialogueStateTracker diff --git a/rasa/core/policies/flow_policy.py b/rasa/core/policies/flow_policy.py index 8d5ec48567c8..c7fb680a1aad 100644 --- a/rasa/core/policies/flow_policy.py +++ b/rasa/core/policies/flow_policy.py @@ -44,13 +44,12 @@ ACTION_SEND_TEXT_NAME, ) from rasa.shared.core.events import Event, SlotSet -from rasa.shared.core.flows.flow import ( +from rasa.shared.core.flows.flow_step import ( END_STEP, ActionFlowStep, BranchFlowStep, ContinueFlowStep, ElseFlowLink, - Flow, FlowStep, GenerateResponseFlowStep, IfFlowLink, @@ -62,8 +61,9 @@ CollectInformationFlowStep, StaticFlowLink, ) +from rasa.shared.core.flows.flow import Flow from rasa.shared.core.flows.flows_list import FlowsList -from rasa.shared.core.flows.flow import EndFlowStep +from rasa.shared.core.flows.flow_step import EndFlowStep from rasa.core.featurizers.tracker_featurizers import TrackerFeaturizer from rasa.core.policies.policy import Policy, PolicyPrediction from rasa.engine.graph import ExecutionContext diff --git a/rasa/dialogue_understanding/commands/correct_slots_command.py b/rasa/dialogue_understanding/commands/correct_slots_command.py index 09d7154985a0..a1f13dcdb648 100644 --- a/rasa/dialogue_understanding/commands/correct_slots_command.py +++ b/rasa/dialogue_understanding/commands/correct_slots_command.py @@ -16,7 +16,7 @@ UserFlowStackFrame, ) from rasa.shared.core.events import Event -from rasa.shared.core.flows.flow import END_STEP, ContinueFlowStep, FlowStep +from rasa.shared.core.flows.flow_step import END_STEP, ContinueFlowStep, FlowStep from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker import rasa.dialogue_understanding.stack.utils as utils diff --git a/rasa/dialogue_understanding/generator/llm_command_generator.py b/rasa/dialogue_understanding/generator/llm_command_generator.py index 674400186aa7..57e105c33ef7 100644 --- a/rasa/dialogue_understanding/generator/llm_command_generator.py +++ b/rasa/dialogue_understanding/generator/llm_command_generator.py @@ -23,11 +23,11 @@ from rasa.engine.recipes.default_recipe import DefaultV1Recipe from rasa.engine.storage.resource import Resource from rasa.engine.storage.storage import ModelStorage -from rasa.shared.core.flows.flow import ( - Flow, +from rasa.shared.core.flows.flow_step import ( FlowStep, CollectInformationFlowStep, ) +from rasa.shared.core.flows.flow import Flow from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker from rasa.shared.core.slots import ( diff --git a/rasa/dialogue_understanding/patterns/cancel.py b/rasa/dialogue_understanding/patterns/cancel.py index b60df2e004cc..ecc3f5b17057 100644 --- a/rasa/dialogue_understanding/patterns/cancel.py +++ b/rasa/dialogue_understanding/patterns/cancel.py @@ -18,7 +18,7 @@ from rasa.shared.core.constants import ACTION_CANCEL_FLOW from rasa.shared.core.domain import Domain from rasa.shared.core.events import Event -from rasa.shared.core.flows.flow import END_STEP, ContinueFlowStep +from rasa.shared.core.flows.flow_step import END_STEP, ContinueFlowStep from rasa.shared.core.trackers import DialogueStateTracker diff --git a/rasa/dialogue_understanding/patterns/collect_information.py b/rasa/dialogue_understanding/patterns/collect_information.py index 9aa35824e888..fa0388014279 100644 --- a/rasa/dialogue_understanding/patterns/collect_information.py +++ b/rasa/dialogue_understanding/patterns/collect_information.py @@ -5,7 +5,7 @@ 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 +from rasa.shared.core.flows.flow_step import SlotRejection FLOW_PATTERN_COLLECT_INFORMATION = ( RASA_DEFAULT_FLOW_PATTERN_PREFIX + "collect_information" diff --git a/rasa/dialogue_understanding/patterns/correction.py b/rasa/dialogue_understanding/patterns/correction.py index 1409bba8fba1..00aa4d127425 100644 --- a/rasa/dialogue_understanding/patterns/correction.py +++ b/rasa/dialogue_understanding/patterns/correction.py @@ -7,7 +7,7 @@ ) from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack from rasa.shared.constants import RASA_DEFAULT_FLOW_PATTERN_PREFIX -from rasa.shared.core.flows.flow import ( +from rasa.shared.core.flows.flow_step import ( START_STEP, ) from rasa.shared.core.trackers import ( @@ -30,7 +30,7 @@ SlotSet, ) from rasa.core.nlg import NaturalLanguageGenerator -from rasa.shared.core.flows.flow import END_STEP, ContinueFlowStep +from rasa.shared.core.flows.flow_step import END_STEP, ContinueFlowStep structlogger = structlog.get_logger() diff --git a/rasa/dialogue_understanding/processor/command_processor.py b/rasa/dialogue_understanding/processor/command_processor.py index 08fa42dadd6d..a25e071d6369 100644 --- a/rasa/dialogue_understanding/processor/command_processor.py +++ b/rasa/dialogue_understanding/processor/command_processor.py @@ -28,7 +28,7 @@ ) from rasa.shared.core.constants import FLOW_HASHES_SLOT from rasa.shared.core.events import Event, SlotSet -from rasa.shared.core.flows.flow import ( +from rasa.shared.core.flows.flow_step import ( CollectInformationFlowStep, ) from rasa.shared.core.flows.flows_list import FlowsList diff --git a/rasa/dialogue_understanding/stack/frames/flow_stack_frame.py b/rasa/dialogue_understanding/stack/frames/flow_stack_frame.py index d611006f74b9..6a662ce1199f 100644 --- a/rasa/dialogue_understanding/stack/frames/flow_stack_frame.py +++ b/rasa/dialogue_understanding/stack/frames/flow_stack_frame.py @@ -4,7 +4,8 @@ from typing import Any, Dict, Optional from rasa.dialogue_understanding.stack.frames import DialogueStackFrame -from rasa.shared.core.flows.flow import START_STEP, Flow, FlowStep +from rasa.shared.core.flows.flow_step import START_STEP, FlowStep +from rasa.shared.core.flows.flow import Flow from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.exceptions import RasaException diff --git a/rasa/dialogue_understanding/stack/utils.py b/rasa/dialogue_understanding/stack/utils.py index 955e9c5acd62..1c83cfc51ea7 100644 --- a/rasa/dialogue_understanding/stack/utils.py +++ b/rasa/dialogue_understanding/stack/utils.py @@ -5,7 +5,7 @@ from rasa.dialogue_understanding.stack.frames import BaseFlowStackFrame from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack from rasa.dialogue_understanding.stack.frames import UserFlowStackFrame -from rasa.shared.core.flows.flow import END_STEP, ContinueFlowStep +from rasa.shared.core.flows.flow_step import END_STEP, ContinueFlowStep from rasa.shared.core.flows.flows_list import FlowsList diff --git a/rasa/shared/core/flows/flow.py b/rasa/shared/core/flows/flow.py index cd5ced35519d..1296c4555c0c 100644 --- a/rasa/shared/core/flows/flow.py +++ b/rasa/shared/core/flows/flow.py @@ -2,47 +2,23 @@ from dataclasses import dataclass from functools import cached_property -from typing import ( - Any, - Dict, - Generator, - List, - Optional, - Protocol, - Set, - Text, - Union, - runtime_checkable, +from typing import Text, Optional, Dict, Any, List, Set + +import rasa.shared +from rasa.shared.constants import RASA_DEFAULT_FLOW_PATTERN_PREFIX +from rasa.shared.core.flows.flow_step import ( + StepSequence, + FlowStep, + LinkFlowStep, + StaticFlowLink, + END_STEP, + START_STEP, + StartFlowStep, + EndFlowStep, + CONTINUE_STEP_PREFIX, + ContinueFlowStep, + CollectInformationFlowStep, ) -import structlog - -from rasa.shared.core.flows.exceptions import ( - UnreachableFlowStepException, - MissingNextLinkException, - ReservedFlowStepIdException, - MissingElseBranchException, - NoNextAllowedForLinkException, - UnresolvedFlowStepIdException, - EmptyStepSequenceException, - EmptyFlowException, -) -from rasa.shared.core.trackers import DialogueStateTracker -from rasa.shared.constants import RASA_DEFAULT_FLOW_PATTERN_PREFIX, UTTER_PREFIX -from rasa.shared.nlu.constants import ENTITY_ATTRIBUTE_TYPE, INTENT_NAME_KEY - -import rasa.shared.utils.io -from rasa.shared.utils.llm import ( - DEFAULT_OPENAI_GENERATE_MODEL_NAME, - DEFAULT_OPENAI_TEMPERATURE, -) - -structlogger = structlog.get_logger() - -START_STEP = "START" - -END_STEP = "END" - -DEFAULT_STEPS = {END_STEP, START_STEP} @dataclass @@ -234,972 +210,3 @@ def fingerprint(self) -> str: def utterances(self) -> Set[str]: """Retrieve all utterances of this flow""" return set().union(*[step.utterances for step in self.step_sequence.steps]) - - -@dataclass -class StepSequence: - child_steps: List[FlowStep] - - @staticmethod - def from_json(steps_config: List[Dict[Text, Any]]) -> StepSequence: - """Used to read steps from parsed YAML. - - Args: - steps_config: The parsed YAML as a dictionary. - - Returns: - The parsed steps. - """ - - flow_steps: List[FlowStep] = [step_from_json(config) for config in steps_config] - - return StepSequence(child_steps=flow_steps) - - def as_json(self) -> List[Dict[Text, Any]]: - """Returns the steps as a dictionary. - - Returns: - The steps as a dictionary. - """ - return [ - step.as_json() - for step in self.child_steps - if not isinstance(step, InternalFlowStep) - ] - - @property - def steps(self) -> List[FlowStep]: - """Returns the steps of the flow.""" - return [ - step - for child_step in self.child_steps - for step in child_step.steps_in_tree() - ] - - def first(self) -> Optional[FlowStep]: - """Returns the first step of the sequence.""" - if len(self.child_steps) == 0: - return None - return self.child_steps[0] - - -def step_from_json(flow_step_config: Dict[Text, Any]) -> FlowStep: - """Used to read flow steps from parsed YAML. - - Args: - flow_step_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow step. - """ - if "action" in flow_step_config: - return ActionFlowStep.from_json(flow_step_config) - if "intent" in flow_step_config: - return UserMessageStep.from_json(flow_step_config) - if "collect" in flow_step_config: - return CollectInformationFlowStep.from_json(flow_step_config) - if "link" in flow_step_config: - return LinkFlowStep.from_json(flow_step_config) - if "set_slots" in flow_step_config: - return SetSlotsFlowStep.from_json(flow_step_config) - if "generation_prompt" in flow_step_config: - return GenerateResponseFlowStep.from_json(flow_step_config) - else: - return BranchFlowStep.from_json(flow_step_config) - - -@dataclass -class FlowStep: - """Represents the configuration of a flow step.""" - - custom_id: Optional[Text] - """The id of the flow step.""" - idx: int - """The index of the step in the flow.""" - description: Optional[Text] - """The description of the flow step.""" - metadata: Dict[Text, Any] - """Additional, unstructured information about this flow step.""" - next: "FlowLinks" - """The next steps of the flow step.""" - - @classmethod - def _from_json(cls, flow_step_config: Dict[Text, Any]) -> FlowStep: - """Used to read flow steps from parsed YAML. - - Args: - flow_step_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow step. - """ - return FlowStep( - # the idx is set later once the flow is created that contains - # this step - idx=-1, - custom_id=flow_step_config.get("id"), - description=flow_step_config.get("description"), - metadata=flow_step_config.get("metadata", {}), - next=FlowLinks.from_json(flow_step_config.get("next", [])), - ) - - def as_json(self) -> Dict[Text, Any]: - """Returns the flow step as a dictionary. - - Returns: - The flow step as a dictionary. - """ - dump = {"next": self.next.as_json(), "id": self.id} - - if self.description: - dump["description"] = self.description - if self.metadata: - dump["metadata"] = self.metadata - return dump - - def steps_in_tree(self) -> Generator[FlowStep, None, None]: - """Returns the steps in the tree of the flow step.""" - yield self - yield from self.next.steps_in_tree() - - @property - def id(self) -> Text: - """Returns the id of the flow step.""" - return self.custom_id or self.default_id() - - def default_id(self) -> str: - """Returns the default id of the flow step.""" - return f"{self.idx}_{self.default_id_postfix()}" - - def default_id_postfix(self) -> str: - """Returns the default id postfix of the flow step.""" - raise NotImplementedError() - - @property - def utterances(self) -> Set[str]: - """Return all the utterances used in this step""" - return set() - - -class InternalFlowStep(FlowStep): - """Represents the configuration of a built-in flow step. - - Built in flow steps are required to manage the lifecycle of a - flow and are not intended to be used by users. - """ - - @classmethod - def from_json(cls, flow_step_config: Dict[Text, Any]) -> ActionFlowStep: - """Used to read flow steps from parsed JSON. - - Args: - flow_step_config: The parsed JSON as a dictionary. - - Returns: - The parsed flow step. - """ - raise ValueError("A start step cannot be parsed.") - - def as_json(self) -> Dict[Text, Any]: - """Returns the flow step as a dictionary. - - Returns: - The flow step as a dictionary. - """ - raise ValueError("A start step cannot be dumped.") - - -@dataclass -class StartFlowStep(InternalFlowStep): - """Represents the configuration of a start flow step.""" - - def __init__(self, start_step_id: Optional[Text]) -> None: - """Initializes a start flow step. - - Args: - start_step: The step to start the flow from. - """ - if start_step_id is not None: - links: List[FlowLink] = [StaticFlowLink(start_step_id)] - else: - links = [] - - super().__init__( - idx=0, - custom_id=START_STEP, - description=None, - metadata={}, - next=FlowLinks(links=links), - ) - - -@dataclass -class EndFlowStep(InternalFlowStep): - """Represents the configuration of an end to a flow.""" - - def __init__(self) -> None: - """Initializes an end flow step.""" - super().__init__( - idx=0, - custom_id=END_STEP, - description=None, - metadata={}, - next=FlowLinks(links=[]), - ) - - -CONTINUE_STEP_PREFIX = "NEXT:" - - -@dataclass -class ContinueFlowStep(InternalFlowStep): - """Represents the configuration of a continue-step flow step.""" - - def __init__(self, next: str) -> None: - """Initializes a continue-step flow step.""" - super().__init__( - idx=0, - custom_id=CONTINUE_STEP_PREFIX + next, - description=None, - metadata={}, - # The continue step links to the step that should be continued. - # The flow policy in a sense only "runs" the logic of a step - # when it transitions to that step, once it is there it will use - # the next link to transition to the next step. This means that - # if we want to "re-run" a step, we need to link to it again. - # This is why the continue step links to the step that should be - # continued. - next=FlowLinks(links=[StaticFlowLink(next)]), - ) - - @staticmethod - def continue_step_for_id(step_id: str) -> str: - return CONTINUE_STEP_PREFIX + step_id - - -@dataclass -class ActionFlowStep(FlowStep): - """Represents the configuration of an action flow step.""" - - action: Text - """The action of the flow step.""" - - @classmethod - def from_json(cls, flow_step_config: Dict[Text, Any]) -> ActionFlowStep: - """Used to read flow steps from parsed YAML. - - Args: - flow_step_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow step. - """ - base = super()._from_json(flow_step_config) - return ActionFlowStep( - action=flow_step_config.get("action", ""), - **base.__dict__, - ) - - def as_json(self) -> Dict[Text, Any]: - """Returns the flow step as a dictionary. - - Returns: - The flow step as a dictionary. - """ - dump = super().as_json() - dump["action"] = self.action - return dump - - def default_id_postfix(self) -> str: - return self.action - - @property - def utterances(self) -> Set[str]: - """Return all the utterances used in this step""" - return {self.action} if self.action.startswith(UTTER_PREFIX) else set() - - -@dataclass -class BranchFlowStep(FlowStep): - """Represents the configuration of a branch flow step.""" - - @classmethod - def from_json(cls, flow_step_config: Dict[Text, Any]) -> BranchFlowStep: - """Used to read flow steps from parsed YAML. - - Args: - flow_step_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow step. - """ - base = super()._from_json(flow_step_config) - return BranchFlowStep(**base.__dict__) - - def as_json(self) -> Dict[Text, Any]: - """Returns the flow step as a dictionary. - - Returns: - The flow step as a dictionary. - """ - dump = super().as_json() - return dump - - def default_id_postfix(self) -> str: - """Returns the default id postfix of the flow step.""" - return "branch" - - -@dataclass -class LinkFlowStep(FlowStep): - """Represents the configuration of a link flow step.""" - - link: Text - """The link of the flow step.""" - - @classmethod - def from_json(cls, flow_step_config: Dict[Text, Any]) -> LinkFlowStep: - """Used to read flow steps from parsed YAML. - - Args: - flow_step_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow step. - """ - base = super()._from_json(flow_step_config) - return LinkFlowStep( - link=flow_step_config.get("link", ""), - **base.__dict__, - ) - - def as_json(self) -> Dict[Text, Any]: - """Returns the flow step as a dictionary. - - Returns: - The flow step as a dictionary. - """ - dump = super().as_json() - dump["link"] = self.link - return dump - - def default_id_postfix(self) -> str: - """Returns the default id postfix of the flow step.""" - return f"link_{self.link}" - - -@dataclass -class TriggerCondition: - """Represents the configuration of a trigger condition.""" - - intent: Text - """The intent to trigger the flow.""" - entities: List[Text] - """The entities to trigger the flow.""" - - def is_triggered(self, intent: Text, entities: List[Text]) -> bool: - """Check if condition is triggered by the given intent and entities. - - Args: - intent: The intent to check. - entities: The entities to check. - - Returns: - Whether the trigger condition is triggered by the given intent and entities. - """ - if self.intent != intent: - return False - if len(self.entities) == 0: - return True - return all(entity in entities for entity in self.entities) - - -@runtime_checkable -class StepThatCanStartAFlow(Protocol): - """Represents a step that can start a flow.""" - - def is_triggered(self, tracker: DialogueStateTracker) -> bool: - """Check if a flow should be started for the tracker - - Args: - tracker: The tracker to check. - - Returns: - Whether a flow should be started for the tracker. - """ - ... - - -@dataclass -class UserMessageStep(FlowStep, StepThatCanStartAFlow): - """Represents the configuration of an intent flow step.""" - - trigger_conditions: List[TriggerCondition] - """The trigger conditions of the flow step.""" - - @classmethod - def from_json(cls, flow_step_config: Dict[Text, Any]) -> UserMessageStep: - """Used to read flow steps from parsed YAML. - - Args: - flow_step_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow step. - """ - base = super()._from_json(flow_step_config) - - trigger_conditions = [] - if "intent" in flow_step_config: - trigger_conditions.append( - TriggerCondition( - intent=flow_step_config["intent"], - entities=flow_step_config.get("entities", []), - ) - ) - elif "or" in flow_step_config: - for trigger_condition in flow_step_config["or"]: - trigger_conditions.append( - TriggerCondition( - intent=trigger_condition.get("intent", ""), - entities=trigger_condition.get("entities", []), - ) - ) - - return UserMessageStep( - trigger_conditions=trigger_conditions, - **base.__dict__, - ) - - def as_json(self) -> Dict[Text, Any]: - """Returns the flow step as a dictionary. - - Returns: - The flow step as a dictionary. - """ - dump = super().as_json() - - if len(self.trigger_conditions) == 1: - dump["intent"] = self.trigger_conditions[0].intent - if self.trigger_conditions[0].entities: - dump["entities"] = self.trigger_conditions[0].entities - elif len(self.trigger_conditions) > 1: - dump["or"] = [ - { - "intent": trigger_condition.intent, - "entities": trigger_condition.entities, - } - for trigger_condition in self.trigger_conditions - ] - - return dump - - def is_triggered(self, tracker: DialogueStateTracker) -> bool: - """Returns whether the flow step is triggered by the given intent and entities. - - Args: - intent: The intent to check. - entities: The entities to check. - - Returns: - Whether the flow step is triggered by the given intent and entities. - """ - if not tracker.latest_message: - return False - - intent: Text = tracker.latest_message.intent.get(INTENT_NAME_KEY, "") - entities: List[Text] = [ - e.get(ENTITY_ATTRIBUTE_TYPE, "") for e in tracker.latest_message.entities - ] - return any( - trigger_condition.is_triggered(intent, entities) - for trigger_condition in self.trigger_conditions - ) - - def default_id_postfix(self) -> str: - """Returns the default id postfix of the flow step.""" - return "intent" - - -DEFAULT_LLM_CONFIG = { - "_type": "openai", - "request_timeout": 5, - "temperature": DEFAULT_OPENAI_TEMPERATURE, - "model_name": DEFAULT_OPENAI_GENERATE_MODEL_NAME, -} - - -@dataclass -class GenerateResponseFlowStep(FlowStep): - """Represents the configuration of a step prompting an LLM.""" - - generation_prompt: Text - """The prompt template of the flow step.""" - llm_config: Optional[Dict[Text, Any]] = None - """The LLM configuration of the flow step.""" - - @classmethod - def from_json(cls, flow_step_config: Dict[Text, Any]) -> GenerateResponseFlowStep: - """Used to read flow steps from parsed YAML. - - Args: - flow_step_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow step. - """ - base = super()._from_json(flow_step_config) - return GenerateResponseFlowStep( - generation_prompt=flow_step_config.get("generation_prompt", ""), - llm_config=flow_step_config.get("llm", None), - **base.__dict__, - ) - - def as_json(self) -> Dict[Text, Any]: - """Returns the flow step as a dictionary. - - Returns: - The flow step as a dictionary. - """ - dump = super().as_json() - dump["generation_prompt"] = self.generation_prompt - if self.llm_config: - dump["llm"] = self.llm_config - - return dump - - def generate(self, tracker: DialogueStateTracker) -> Optional[Text]: - """Generates a response for the given tracker. - - Args: - tracker: The tracker to generate a response for. - - Returns: - The generated response. - """ - from rasa.shared.utils.llm import llm_factory, tracker_as_readable_transcript - from jinja2 import Template - - context = { - "history": tracker_as_readable_transcript(tracker, max_turns=5), - "latest_user_message": tracker.latest_message.text - if tracker.latest_message - else "", - } - context.update(tracker.current_slot_values()) - - llm = llm_factory(self.llm_config, DEFAULT_LLM_CONFIG) - prompt = Template(self.generation_prompt).render(context) - - try: - return llm(prompt) - except Exception as e: - # unfortunately, langchain does not wrap LLM exceptions which means - # we have to catch all exceptions here - structlogger.error( - "flow.generate_step.llm.error", error=e, step=self.id, prompt=prompt - ) - return None - - def default_id_postfix(self) -> str: - return "generate" - - -@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: 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 - """Whether to always ask the question even if the slot is already filled.""" - reset_after_flow_ends: bool = True - """Determines whether to reset the slot value at the end of the flow.""" - - @classmethod - def from_json(cls, flow_step_config: Dict[Text, Any]) -> CollectInformationFlowStep: - """Used to read flow steps from parsed YAML. - - Args: - flow_step_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow step. - """ - base = super()._from_json(flow_step_config) - return CollectInformationFlowStep( - collect=flow_step_config["collect"], - utter=flow_step_config.get( - "utter", f"utter_ask_{flow_step_config['collect']}" - ), - ask_before_filling=flow_step_config.get("ask_before_filling", False), - reset_after_flow_ends=flow_step_config.get("reset_after_flow_ends", True), - rejections=[ - SlotRejection.from_dict(rejection) - for rejection in flow_step_config.get("rejections", []) - ], - **base.__dict__, - ) - - def as_json(self) -> Dict[Text, Any]: - """Returns the flow step as a dictionary. - - Returns: - The flow step as a dictionary. - """ - dump = super().as_json() - dump["collect"] = self.collect - dump["utter"] = self.utter - dump["ask_before_filling"] = self.ask_before_filling - dump["reset_after_flow_ends"] = self.reset_after_flow_ends - dump["rejections"] = [rejection.as_dict() for rejection in self.rejections] - - return dump - - def default_id_postfix(self) -> str: - """Returns the default id postfix of the flow step.""" - return f"collect_{self.collect}" - - @property - def utterances(self) -> Set[str]: - """Return all the utterances used in this step""" - return {self.utter} | {r.utter for r in self.rejections} - - -@dataclass -class SetSlotsFlowStep(FlowStep): - """Represents the configuration of a set_slots flow step.""" - - slots: List[Dict[str, Any]] - """Slots to set of the flow step.""" - - @classmethod - def from_json(cls, flow_step_config: Dict[Text, Any]) -> SetSlotsFlowStep: - """Used to read flow steps from parsed YAML. - - Args: - flow_step_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow step. - """ - base = super()._from_json(flow_step_config) - slots = [ - {"key": k, "value": v} - for slot in flow_step_config.get("set_slots", []) - for k, v in slot.items() - ] - return SetSlotsFlowStep( - slots=slots, - **base.__dict__, - ) - - def as_json(self) -> Dict[Text, Any]: - """Returns the flow step as a dictionary. - - Returns: - The flow step as a dictionary. - """ - dump = super().as_json() - dump["set_slots"] = [{slot["key"]: slot["value"]} for slot in self.slots] - return dump - - def default_id_postfix(self) -> str: - """Returns the default id postfix of the flow step.""" - return "set_slots" - - -@dataclass -class FlowLinks: - """Represents the configuration of a list of flow links.""" - - links: List[FlowLink] - - @staticmethod - def from_json(flow_links_config: Union[str, List[Dict[Text, Any]]]) -> FlowLinks: - """Used to read flow links from parsed YAML. - - Args: - flow_links_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow links. - """ - if not flow_links_config: - return FlowLinks(links=[]) - - if isinstance(flow_links_config, str): - return FlowLinks(links=[StaticFlowLink.from_json(flow_links_config)]) - - return FlowLinks( - links=[ - BranchBasedLink.from_json(link_config) - for link_config in flow_links_config - if link_config - ] - ) - - def as_json(self) -> Optional[Union[str, List[Dict[str, Any]]]]: - """Returns the flow links as a dictionary. - - Returns: - The flow links as a dictionary. - """ - if not self.links: - return None - - if len(self.links) == 1 and isinstance(self.links[0], StaticFlowLink): - return self.links[0].as_json() - - return [link.as_json() for link in self.links] - - def no_link_available(self) -> bool: - """Returns whether no link is available.""" - return len(self.links) == 0 - - def steps_in_tree(self) -> Generator[FlowStep, None, None]: - """Returns the steps in the tree of the flow links.""" - for link in self.links: - yield from link.steps_in_tree() - - -class FlowLink: - """Represents a flow link.""" - - @property - def target(self) -> Optional[Text]: - """Returns the target of the flow link. - - Returns: - The target of the flow link. - """ - raise NotImplementedError() - - def as_json(self) -> Any: - """Returns the flow link as a dictionary. - - Returns: - The flow link as a dictionary. - """ - raise NotImplementedError() - - @staticmethod - def from_json(link_config: Any) -> FlowLink: - """Used to read flow links from parsed YAML. - - Args: - link_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow link. - """ - raise NotImplementedError() - - def steps_in_tree(self) -> Generator[FlowStep, None, None]: - """Returns the steps in the tree of the flow link.""" - raise NotImplementedError() - - def child_steps(self) -> List[FlowStep]: - """Returns the child steps of the flow link.""" - raise NotImplementedError() - - -@dataclass -class BranchBasedLink(FlowLink): - target_reference: Union[Text, StepSequence] - """The id of the linked flow.""" - - def steps_in_tree(self) -> Generator[FlowStep, None, None]: - """Returns the steps in the tree of the flow link.""" - if isinstance(self.target_reference, StepSequence): - yield from self.target_reference.steps - - def child_steps(self) -> List[FlowStep]: - """Returns the child steps of the flow link.""" - if isinstance(self.target_reference, StepSequence): - return self.target_reference.child_steps - else: - return [] - - @property - def target(self) -> Optional[Text]: - """Returns the target of the flow link.""" - if isinstance(self.target_reference, StepSequence): - if first := self.target_reference.first(): - return first.id - else: - return None - else: - return self.target_reference - - @staticmethod - def from_json(link_config: Dict[Text, Any]) -> BranchBasedLink: - """Used to read a single flow links from parsed YAML. - - Args: - link_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow link. - """ - if "if" in link_config: - return IfFlowLink.from_json(link_config) - else: - return ElseFlowLink.from_json(link_config) - - -@dataclass -class IfFlowLink(BranchBasedLink): - """Represents the configuration of an if flow link.""" - - condition: Optional[Text] - """The condition of the linked flow.""" - - @staticmethod - def from_json(link_config: Dict[Text, Any]) -> IfFlowLink: - """Used to read flow links from parsed YAML. - - Args: - link_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow link. - """ - if isinstance(link_config["then"], str): - return IfFlowLink( - target_reference=link_config["then"], condition=link_config.get("if") - ) - else: - return IfFlowLink( - target_reference=StepSequence.from_json(link_config["then"]), - condition=link_config.get("if"), - ) - - def as_json(self) -> Dict[Text, Any]: - """Returns the flow link as a dictionary. - - Returns: - The flow link as a dictionary. - """ - return { - "if": self.condition, - "then": self.target_reference.as_json() - if isinstance(self.target_reference, StepSequence) - else self.target_reference, - } - - -@dataclass -class ElseFlowLink(BranchBasedLink): - """Represents the configuration of an else flow link.""" - - @staticmethod - def from_json(link_config: Dict[Text, Any]) -> ElseFlowLink: - """Used to read flow links from parsed YAML. - - Args: - link_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow link. - """ - if isinstance(link_config["else"], str): - return ElseFlowLink(target_reference=link_config["else"]) - else: - return ElseFlowLink( - target_reference=StepSequence.from_json(link_config["else"]) - ) - - def as_json(self) -> Dict[Text, Any]: - """Returns the flow link as a dictionary. - - Returns: - The flow link as a dictionary. - """ - return { - "else": self.target_reference.as_json() - if isinstance(self.target_reference, StepSequence) - else self.target_reference - } - - -@dataclass -class StaticFlowLink(FlowLink): - """Represents the configuration of a static flow link.""" - - target_id: Text - """The id of the linked flow.""" - - @staticmethod - def from_json(link_config: Text) -> StaticFlowLink: - """Used to read flow links from parsed YAML. - - Args: - link_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow link. - """ - return StaticFlowLink(link_config) - - def as_json(self) -> Text: - """Returns the flow link as a dictionary. - - Returns: - The flow link as a dictionary. - """ - return self.target - - def steps_in_tree(self) -> Generator[FlowStep, None, None]: - """Returns the steps in the tree of the flow link.""" - # static links do not have any child steps - yield from [] - - def child_steps(self) -> List[FlowStep]: - """Returns the child steps of the flow link.""" - return [] - - @property - def target(self) -> Optional[Text]: - """Returns the target of the flow link.""" - return self.target_id diff --git a/rasa/shared/core/flows/flow_step.py b/rasa/shared/core/flows/flow_step.py new file mode 100644 index 000000000000..06a0e8c9f359 --- /dev/null +++ b/rasa/shared/core/flows/flow_step.py @@ -0,0 +1,1002 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import ( + Any, + Dict, + Generator, + List, + Optional, + Protocol, + Set, + Text, + Union, + runtime_checkable, +) +import structlog + +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.constants import UTTER_PREFIX +from rasa.shared.nlu.constants import ENTITY_ATTRIBUTE_TYPE, INTENT_NAME_KEY + +from rasa.shared.utils.llm import ( + DEFAULT_OPENAI_GENERATE_MODEL_NAME, + DEFAULT_OPENAI_TEMPERATURE, +) + +structlogger = structlog.get_logger() + +START_STEP = "START" + +END_STEP = "END" + +DEFAULT_STEPS = {END_STEP, START_STEP} + + +@dataclass +class StepSequence: + child_steps: List[FlowStep] + + @staticmethod + def from_json(steps_config: List[Dict[Text, Any]]) -> StepSequence: + """Used to read steps from parsed YAML. + + Args: + steps_config: The parsed YAML as a dictionary. + + Returns: + The parsed steps. + """ + + flow_steps: List[FlowStep] = [step_from_json(config) for config in steps_config] + + return StepSequence(child_steps=flow_steps) + + def as_json(self) -> List[Dict[Text, Any]]: + """Returns the steps as a dictionary. + + Returns: + The steps as a dictionary. + """ + return [ + step.as_json() + for step in self.child_steps + if not isinstance(step, InternalFlowStep) + ] + + @property + def steps(self) -> List[FlowStep]: + """Returns the steps of the flow.""" + return [ + step + for child_step in self.child_steps + for step in child_step.steps_in_tree() + ] + + def first(self) -> Optional[FlowStep]: + """Returns the first step of the sequence.""" + if len(self.child_steps) == 0: + return None + return self.child_steps[0] + + +def step_from_json(flow_step_config: Dict[Text, Any]) -> FlowStep: + """Used to read flow steps from parsed YAML. + + Args: + flow_step_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow step. + """ + if "action" in flow_step_config: + return ActionFlowStep.from_json(flow_step_config) + if "intent" in flow_step_config: + return UserMessageStep.from_json(flow_step_config) + if "collect" in flow_step_config: + return CollectInformationFlowStep.from_json(flow_step_config) + if "link" in flow_step_config: + return LinkFlowStep.from_json(flow_step_config) + if "set_slots" in flow_step_config: + return SetSlotsFlowStep.from_json(flow_step_config) + if "generation_prompt" in flow_step_config: + return GenerateResponseFlowStep.from_json(flow_step_config) + else: + return BranchFlowStep.from_json(flow_step_config) + + +@dataclass +class FlowStep: + """Represents the configuration of a flow step.""" + + custom_id: Optional[Text] + """The id of the flow step.""" + idx: int + """The index of the step in the flow.""" + description: Optional[Text] + """The description of the flow step.""" + metadata: Dict[Text, Any] + """Additional, unstructured information about this flow step.""" + next: "FlowLinks" + """The next steps of the flow step.""" + + @classmethod + def _from_json(cls, flow_step_config: Dict[Text, Any]) -> FlowStep: + """Used to read flow steps from parsed YAML. + + Args: + flow_step_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow step. + """ + return FlowStep( + # the idx is set later once the flow is created that contains + # this step + idx=-1, + custom_id=flow_step_config.get("id"), + description=flow_step_config.get("description"), + metadata=flow_step_config.get("metadata", {}), + next=FlowLinks.from_json(flow_step_config.get("next", [])), + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + dump = {"next": self.next.as_json(), "id": self.id} + + if self.description: + dump["description"] = self.description + if self.metadata: + dump["metadata"] = self.metadata + return dump + + def steps_in_tree(self) -> Generator[FlowStep, None, None]: + """Returns the steps in the tree of the flow step.""" + yield self + yield from self.next.steps_in_tree() + + @property + def id(self) -> Text: + """Returns the id of the flow step.""" + return self.custom_id or self.default_id() + + def default_id(self) -> str: + """Returns the default id of the flow step.""" + return f"{self.idx}_{self.default_id_postfix()}" + + def default_id_postfix(self) -> str: + """Returns the default id postfix of the flow step.""" + raise NotImplementedError() + + @property + def utterances(self) -> Set[str]: + """Return all the utterances used in this step""" + return set() + + +class InternalFlowStep(FlowStep): + """Represents the configuration of a built-in flow step. + + Built in flow steps are required to manage the lifecycle of a + flow and are not intended to be used by users. + """ + + @classmethod + def from_json(cls, flow_step_config: Dict[Text, Any]) -> ActionFlowStep: + """Used to read flow steps from parsed JSON. + + Args: + flow_step_config: The parsed JSON as a dictionary. + + Returns: + The parsed flow step. + """ + raise ValueError("A start step cannot be parsed.") + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + raise ValueError("A start step cannot be dumped.") + + +@dataclass +class StartFlowStep(InternalFlowStep): + """Represents the configuration of a start flow step.""" + + def __init__(self, start_step_id: Optional[Text]) -> None: + """Initializes a start flow step. + + Args: + start_step: The step to start the flow from. + """ + if start_step_id is not None: + links: List[FlowLink] = [StaticFlowLink(start_step_id)] + else: + links = [] + + super().__init__( + idx=0, + custom_id=START_STEP, + description=None, + metadata={}, + next=FlowLinks(links=links), + ) + + +@dataclass +class EndFlowStep(InternalFlowStep): + """Represents the configuration of an end to a flow.""" + + def __init__(self) -> None: + """Initializes an end flow step.""" + super().__init__( + idx=0, + custom_id=END_STEP, + description=None, + metadata={}, + next=FlowLinks(links=[]), + ) + + +CONTINUE_STEP_PREFIX = "NEXT:" + + +@dataclass +class ContinueFlowStep(InternalFlowStep): + """Represents the configuration of a continue-step flow step.""" + + def __init__(self, next: str) -> None: + """Initializes a continue-step flow step.""" + super().__init__( + idx=0, + custom_id=CONTINUE_STEP_PREFIX + next, + description=None, + metadata={}, + # The continue step links to the step that should be continued. + # The flow policy in a sense only "runs" the logic of a step + # when it transitions to that step, once it is there it will use + # the next link to transition to the next step. This means that + # if we want to "re-run" a step, we need to link to it again. + # This is why the continue step links to the step that should be + # continued. + next=FlowLinks(links=[StaticFlowLink(next)]), + ) + + @staticmethod + def continue_step_for_id(step_id: str) -> str: + return CONTINUE_STEP_PREFIX + step_id + + +@dataclass +class ActionFlowStep(FlowStep): + """Represents the configuration of an action flow step.""" + + action: Text + """The action of the flow step.""" + + @classmethod + def from_json(cls, flow_step_config: Dict[Text, Any]) -> ActionFlowStep: + """Used to read flow steps from parsed YAML. + + Args: + flow_step_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow step. + """ + base = super()._from_json(flow_step_config) + return ActionFlowStep( + action=flow_step_config.get("action", ""), + **base.__dict__, + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + dump = super().as_json() + dump["action"] = self.action + return dump + + def default_id_postfix(self) -> str: + return self.action + + @property + def utterances(self) -> Set[str]: + """Return all the utterances used in this step""" + return {self.action} if self.action.startswith(UTTER_PREFIX) else set() + + +@dataclass +class BranchFlowStep(FlowStep): + """Represents the configuration of a branch flow step.""" + + @classmethod + def from_json(cls, flow_step_config: Dict[Text, Any]) -> BranchFlowStep: + """Used to read flow steps from parsed YAML. + + Args: + flow_step_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow step. + """ + base = super()._from_json(flow_step_config) + return BranchFlowStep(**base.__dict__) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + dump = super().as_json() + return dump + + def default_id_postfix(self) -> str: + """Returns the default id postfix of the flow step.""" + return "branch" + + +@dataclass +class LinkFlowStep(FlowStep): + """Represents the configuration of a link flow step.""" + + link: Text + """The link of the flow step.""" + + @classmethod + def from_json(cls, flow_step_config: Dict[Text, Any]) -> LinkFlowStep: + """Used to read flow steps from parsed YAML. + + Args: + flow_step_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow step. + """ + base = super()._from_json(flow_step_config) + return LinkFlowStep( + link=flow_step_config.get("link", ""), + **base.__dict__, + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + dump = super().as_json() + dump["link"] = self.link + return dump + + def default_id_postfix(self) -> str: + """Returns the default id postfix of the flow step.""" + return f"link_{self.link}" + + +@dataclass +class TriggerCondition: + """Represents the configuration of a trigger condition.""" + + intent: Text + """The intent to trigger the flow.""" + entities: List[Text] + """The entities to trigger the flow.""" + + def is_triggered(self, intent: Text, entities: List[Text]) -> bool: + """Check if condition is triggered by the given intent and entities. + + Args: + intent: The intent to check. + entities: The entities to check. + + Returns: + Whether the trigger condition is triggered by the given intent and entities. + """ + if self.intent != intent: + return False + if len(self.entities) == 0: + return True + return all(entity in entities for entity in self.entities) + + +@runtime_checkable +class StepThatCanStartAFlow(Protocol): + """Represents a step that can start a flow.""" + + def is_triggered(self, tracker: DialogueStateTracker) -> bool: + """Check if a flow should be started for the tracker + + Args: + tracker: The tracker to check. + + Returns: + Whether a flow should be started for the tracker. + """ + ... + + +@dataclass +class UserMessageStep(FlowStep, StepThatCanStartAFlow): + """Represents the configuration of an intent flow step.""" + + trigger_conditions: List[TriggerCondition] + """The trigger conditions of the flow step.""" + + @classmethod + def from_json(cls, flow_step_config: Dict[Text, Any]) -> UserMessageStep: + """Used to read flow steps from parsed YAML. + + Args: + flow_step_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow step. + """ + base = super()._from_json(flow_step_config) + + trigger_conditions = [] + if "intent" in flow_step_config: + trigger_conditions.append( + TriggerCondition( + intent=flow_step_config["intent"], + entities=flow_step_config.get("entities", []), + ) + ) + elif "or" in flow_step_config: + for trigger_condition in flow_step_config["or"]: + trigger_conditions.append( + TriggerCondition( + intent=trigger_condition.get("intent", ""), + entities=trigger_condition.get("entities", []), + ) + ) + + return UserMessageStep( + trigger_conditions=trigger_conditions, + **base.__dict__, + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + dump = super().as_json() + + if len(self.trigger_conditions) == 1: + dump["intent"] = self.trigger_conditions[0].intent + if self.trigger_conditions[0].entities: + dump["entities"] = self.trigger_conditions[0].entities + elif len(self.trigger_conditions) > 1: + dump["or"] = [ + { + "intent": trigger_condition.intent, + "entities": trigger_condition.entities, + } + for trigger_condition in self.trigger_conditions + ] + + return dump + + def is_triggered(self, tracker: DialogueStateTracker) -> bool: + """Returns whether the flow step is triggered by the given intent and entities. + + Args: + intent: The intent to check. + entities: The entities to check. + + Returns: + Whether the flow step is triggered by the given intent and entities. + """ + if not tracker.latest_message: + return False + + intent: Text = tracker.latest_message.intent.get(INTENT_NAME_KEY, "") + entities: List[Text] = [ + e.get(ENTITY_ATTRIBUTE_TYPE, "") for e in tracker.latest_message.entities + ] + return any( + trigger_condition.is_triggered(intent, entities) + for trigger_condition in self.trigger_conditions + ) + + def default_id_postfix(self) -> str: + """Returns the default id postfix of the flow step.""" + return "intent" + + +DEFAULT_LLM_CONFIG = { + "_type": "openai", + "request_timeout": 5, + "temperature": DEFAULT_OPENAI_TEMPERATURE, + "model_name": DEFAULT_OPENAI_GENERATE_MODEL_NAME, +} + + +@dataclass +class GenerateResponseFlowStep(FlowStep): + """Represents the configuration of a step prompting an LLM.""" + + generation_prompt: Text + """The prompt template of the flow step.""" + llm_config: Optional[Dict[Text, Any]] = None + """The LLM configuration of the flow step.""" + + @classmethod + def from_json(cls, flow_step_config: Dict[Text, Any]) -> GenerateResponseFlowStep: + """Used to read flow steps from parsed YAML. + + Args: + flow_step_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow step. + """ + base = super()._from_json(flow_step_config) + return GenerateResponseFlowStep( + generation_prompt=flow_step_config.get("generation_prompt", ""), + llm_config=flow_step_config.get("llm", None), + **base.__dict__, + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + dump = super().as_json() + dump["generation_prompt"] = self.generation_prompt + if self.llm_config: + dump["llm"] = self.llm_config + + return dump + + def generate(self, tracker: DialogueStateTracker) -> Optional[Text]: + """Generates a response for the given tracker. + + Args: + tracker: The tracker to generate a response for. + + Returns: + The generated response. + """ + from rasa.shared.utils.llm import llm_factory, tracker_as_readable_transcript + from jinja2 import Template + + context = { + "history": tracker_as_readable_transcript(tracker, max_turns=5), + "latest_user_message": tracker.latest_message.text + if tracker.latest_message + else "", + } + context.update(tracker.current_slot_values()) + + llm = llm_factory(self.llm_config, DEFAULT_LLM_CONFIG) + prompt = Template(self.generation_prompt).render(context) + + try: + return llm(prompt) + except Exception as e: + # unfortunately, langchain does not wrap LLM exceptions which means + # we have to catch all exceptions here + structlogger.error( + "flow.generate_step.llm.error", error=e, step=self.id, prompt=prompt + ) + return None + + def default_id_postfix(self) -> str: + return "generate" + + +@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: 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 + """Whether to always ask the question even if the slot is already filled.""" + reset_after_flow_ends: bool = True + """Determines whether to reset the slot value at the end of the flow.""" + + @classmethod + def from_json(cls, flow_step_config: Dict[Text, Any]) -> CollectInformationFlowStep: + """Used to read flow steps from parsed YAML. + + Args: + flow_step_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow step. + """ + base = super()._from_json(flow_step_config) + return CollectInformationFlowStep( + collect=flow_step_config["collect"], + utter=flow_step_config.get( + "utter", f"utter_ask_{flow_step_config['collect']}" + ), + ask_before_filling=flow_step_config.get("ask_before_filling", False), + reset_after_flow_ends=flow_step_config.get("reset_after_flow_ends", True), + rejections=[ + SlotRejection.from_dict(rejection) + for rejection in flow_step_config.get("rejections", []) + ], + **base.__dict__, + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + dump = super().as_json() + dump["collect"] = self.collect + dump["utter"] = self.utter + dump["ask_before_filling"] = self.ask_before_filling + dump["reset_after_flow_ends"] = self.reset_after_flow_ends + dump["rejections"] = [rejection.as_dict() for rejection in self.rejections] + + return dump + + def default_id_postfix(self) -> str: + """Returns the default id postfix of the flow step.""" + return f"collect_{self.collect}" + + @property + def utterances(self) -> Set[str]: + """Return all the utterances used in this step""" + return {self.utter} | {r.utter for r in self.rejections} + + +@dataclass +class SetSlotsFlowStep(FlowStep): + """Represents the configuration of a set_slots flow step.""" + + slots: List[Dict[str, Any]] + """Slots to set of the flow step.""" + + @classmethod + def from_json(cls, flow_step_config: Dict[Text, Any]) -> SetSlotsFlowStep: + """Used to read flow steps from parsed YAML. + + Args: + flow_step_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow step. + """ + base = super()._from_json(flow_step_config) + slots = [ + {"key": k, "value": v} + for slot in flow_step_config.get("set_slots", []) + for k, v in slot.items() + ] + return SetSlotsFlowStep( + slots=slots, + **base.__dict__, + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + dump = super().as_json() + dump["set_slots"] = [{slot["key"]: slot["value"]} for slot in self.slots] + return dump + + def default_id_postfix(self) -> str: + """Returns the default id postfix of the flow step.""" + return "set_slots" + + +@dataclass +class FlowLinks: + """Represents the configuration of a list of flow links.""" + + links: List[FlowLink] + + @staticmethod + def from_json(flow_links_config: Union[str, List[Dict[Text, Any]]]) -> FlowLinks: + """Used to read flow links from parsed YAML. + + Args: + flow_links_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow links. + """ + if not flow_links_config: + return FlowLinks(links=[]) + + if isinstance(flow_links_config, str): + return FlowLinks(links=[StaticFlowLink.from_json(flow_links_config)]) + + return FlowLinks( + links=[ + BranchBasedLink.from_json(link_config) + for link_config in flow_links_config + if link_config + ] + ) + + def as_json(self) -> Optional[Union[str, List[Dict[str, Any]]]]: + """Returns the flow links as a dictionary. + + Returns: + The flow links as a dictionary. + """ + if not self.links: + return None + + if len(self.links) == 1 and isinstance(self.links[0], StaticFlowLink): + return self.links[0].as_json() + + return [link.as_json() for link in self.links] + + def no_link_available(self) -> bool: + """Returns whether no link is available.""" + return len(self.links) == 0 + + def steps_in_tree(self) -> Generator[FlowStep, None, None]: + """Returns the steps in the tree of the flow links.""" + for link in self.links: + yield from link.steps_in_tree() + + +class FlowLink: + """Represents a flow link.""" + + @property + def target(self) -> Optional[Text]: + """Returns the target of the flow link. + + Returns: + The target of the flow link. + """ + raise NotImplementedError() + + def as_json(self) -> Any: + """Returns the flow link as a dictionary. + + Returns: + The flow link as a dictionary. + """ + raise NotImplementedError() + + @staticmethod + def from_json(link_config: Any) -> FlowLink: + """Used to read flow links from parsed YAML. + + Args: + link_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow link. + """ + raise NotImplementedError() + + def steps_in_tree(self) -> Generator[FlowStep, None, None]: + """Returns the steps in the tree of the flow link.""" + raise NotImplementedError() + + def child_steps(self) -> List[FlowStep]: + """Returns the child steps of the flow link.""" + raise NotImplementedError() + + +@dataclass +class BranchBasedLink(FlowLink): + target_reference: Union[Text, StepSequence] + """The id of the linked flow.""" + + def steps_in_tree(self) -> Generator[FlowStep, None, None]: + """Returns the steps in the tree of the flow link.""" + if isinstance(self.target_reference, StepSequence): + yield from self.target_reference.steps + + def child_steps(self) -> List[FlowStep]: + """Returns the child steps of the flow link.""" + if isinstance(self.target_reference, StepSequence): + return self.target_reference.child_steps + else: + return [] + + @property + def target(self) -> Optional[Text]: + """Returns the target of the flow link.""" + if isinstance(self.target_reference, StepSequence): + if first := self.target_reference.first(): + return first.id + else: + return None + else: + return self.target_reference + + @staticmethod + def from_json(link_config: Dict[Text, Any]) -> BranchBasedLink: + """Used to read a single flow links from parsed YAML. + + Args: + link_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow link. + """ + if "if" in link_config: + return IfFlowLink.from_json(link_config) + else: + return ElseFlowLink.from_json(link_config) + + +@dataclass +class IfFlowLink(BranchBasedLink): + """Represents the configuration of an if flow link.""" + + condition: Optional[Text] + """The condition of the linked flow.""" + + @staticmethod + def from_json(link_config: Dict[Text, Any]) -> IfFlowLink: + """Used to read flow links from parsed YAML. + + Args: + link_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow link. + """ + if isinstance(link_config["then"], str): + return IfFlowLink( + target_reference=link_config["then"], condition=link_config.get("if") + ) + else: + return IfFlowLink( + target_reference=StepSequence.from_json(link_config["then"]), + condition=link_config.get("if"), + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow link as a dictionary. + + Returns: + The flow link as a dictionary. + """ + return { + "if": self.condition, + "then": self.target_reference.as_json() + if isinstance(self.target_reference, StepSequence) + else self.target_reference, + } + + +@dataclass +class ElseFlowLink(BranchBasedLink): + """Represents the configuration of an else flow link.""" + + @staticmethod + def from_json(link_config: Dict[Text, Any]) -> ElseFlowLink: + """Used to read flow links from parsed YAML. + + Args: + link_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow link. + """ + if isinstance(link_config["else"], str): + return ElseFlowLink(target_reference=link_config["else"]) + else: + return ElseFlowLink( + target_reference=StepSequence.from_json(link_config["else"]) + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow link as a dictionary. + + Returns: + The flow link as a dictionary. + """ + return { + "else": self.target_reference.as_json() + if isinstance(self.target_reference, StepSequence) + else self.target_reference + } + + +@dataclass +class StaticFlowLink(FlowLink): + """Represents the configuration of a static flow link.""" + + target_id: Text + """The id of the linked flow.""" + + @staticmethod + def from_json(link_config: Text) -> StaticFlowLink: + """Used to read flow links from parsed YAML. + + Args: + link_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow link. + """ + return StaticFlowLink(link_config) + + def as_json(self) -> Text: + """Returns the flow link as a dictionary. + + Returns: + The flow link as a dictionary. + """ + return self.target + + def steps_in_tree(self) -> Generator[FlowStep, None, None]: + """Returns the steps in the tree of the flow link.""" + # static links do not have any child steps + yield from [] + + def child_steps(self) -> List[FlowStep]: + """Returns the child steps of the flow link.""" + return [] + + @property + def target(self) -> Optional[Text]: + """Returns the target of the flow link.""" + return self.target_id diff --git a/rasa/shared/core/flows/validation.py b/rasa/shared/core/flows/validation.py index 63746f6e4619..5c1501a2e16b 100644 --- a/rasa/shared/core/flows/validation.py +++ b/rasa/shared/core/flows/validation.py @@ -10,8 +10,7 @@ UnresolvedFlowStepIdException, UnreachableFlowStepException, ) -from rasa.shared.core.flows.flow import ( - Flow, +from rasa.shared.core.flows.flow_step import ( BranchBasedLink, DEFAULT_STEPS, CONTINUE_STEP_PREFIX, @@ -20,6 +19,7 @@ LinkFlowStep, FlowStep, ) +from rasa.shared.core.flows.flow import Flow def validate_flow(flow: Flow) -> None: diff --git a/rasa/validator.py b/rasa/validator.py index b773c8c45b62..73d120b8e5a2 100644 --- a/rasa/validator.py +++ b/rasa/validator.py @@ -7,7 +7,7 @@ from pypred import Predicate import rasa.core.training.story_conflict -from rasa.shared.core.flows.flow import ( +from rasa.shared.core.flows.flow_step import ( ActionFlowStep, BranchFlowStep, CollectInformationFlowStep, diff --git a/tests/dialogue_understanding/commands/test_handle_code_change_command.py b/tests/dialogue_understanding/commands/test_handle_code_change_command.py index 5fa9eda2044b..c41f39ba8008 100644 --- a/tests/dialogue_understanding/commands/test_handle_code_change_command.py +++ b/tests/dialogue_understanding/commands/test_handle_code_change_command.py @@ -16,7 +16,7 @@ ) from rasa.shared.core.domain import Domain from rasa.shared.core.events import SlotSet -from rasa.shared.core.flows.flow import ( +from rasa.shared.core.flows.flow_step import ( START_STEP, ContinueFlowStep, END_STEP, diff --git a/tests/dialogue_understanding/generator/test_llm_command_generator.py b/tests/dialogue_understanding/generator/test_llm_command_generator.py index 887bfb049a8e..830a789b4cca 100644 --- a/tests/dialogue_understanding/generator/test_llm_command_generator.py +++ b/tests/dialogue_understanding/generator/test_llm_command_generator.py @@ -26,7 +26,7 @@ from rasa.engine.storage.resource import Resource from rasa.engine.storage.storage import ModelStorage from rasa.shared.core.events import BotUttered, SlotSet, UserUttered -from rasa.shared.core.flows.flow import ( +from rasa.shared.core.flows.flow_step import ( CollectInformationFlowStep, SlotRejection, ) diff --git a/tests/dialogue_understanding/stack/frames/test_flow_frame.py b/tests/dialogue_understanding/stack/frames/test_flow_frame.py index db3a9fdda842..7df9bbaa69af 100644 --- a/tests/dialogue_understanding/stack/frames/test_flow_frame.py +++ b/tests/dialogue_understanding/stack/frames/test_flow_frame.py @@ -6,12 +6,12 @@ UserFlowStackFrame, FlowStackFrameType, ) -from rasa.shared.core.flows.flow import ( +from rasa.shared.core.flows.flow_step import ( ActionFlowStep, - Flow, FlowLinks, StepSequence, ) +from rasa.shared.core.flows.flow import Flow from rasa.shared.core.flows.flows_list import FlowsList From ab653db9ba77aa9373cd4326d7754272fbb094d8 Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Wed, 25 Oct 2023 17:18:10 +0200 Subject: [PATCH 16/31] moved exceptions to the validation code --- rasa/shared/core/flows/exceptions.py | 160 ------------------------- rasa/shared/core/flows/validation.py | 168 +++++++++++++++++++++++++-- tests/core/flows/test_flow.py | 8 +- 3 files changed, 162 insertions(+), 174 deletions(-) delete mode 100644 rasa/shared/core/flows/exceptions.py diff --git a/rasa/shared/core/flows/exceptions.py b/rasa/shared/core/flows/exceptions.py deleted file mode 100644 index 7e63feda4126..000000000000 --- a/rasa/shared/core/flows/exceptions.py +++ /dev/null @@ -1,160 +0,0 @@ -from __future__ import annotations - -from typing import Optional - -from rasa.shared.exceptions import RasaException - - -class UnreachableFlowStepException(RasaException): - """Raised when a flow step is unreachable.""" - - def __init__(self, step_id: str, flow_id: str) -> None: - """Initializes the exception.""" - self.step_id = step_id - self.flow_id = flow_id - - def __str__(self) -> str: - """Return a string representation of the exception.""" - return ( - f"Step '{self.step_id}' in flow '{self.flow_id}' can not be reached " - f"from the start step. Please make sure that all steps can be reached " - f"from the start step, e.g. by " - f"checking that another step points to this step." - ) - - -class MissingNextLinkException(RasaException): - """Raised when a flow step is missing a next link.""" - - def __init__(self, step_id: str, flow_id: str) -> None: - """Initializes the exception.""" - self.step_id = step_id - self.flow_id = flow_id - - def __str__(self) -> str: - """Return a string representation of the exception.""" - return ( - f"Step '{self.step_id}' in flow '{self.flow_id}' is missing a `next`. " - f"As a last step of a branch, it is required to have one. " - ) - - -class ReservedFlowStepIdException(RasaException): - """Raised when a flow step is using a reserved id.""" - - def __init__(self, step_id: str, flow_id: str) -> None: - """Initializes the exception.""" - self.step_id = step_id - self.flow_id = flow_id - - def __str__(self) -> str: - """Return a string representation of the exception.""" - return ( - f"Step '{self.step_id}' in flow '{self.flow_id}' is using the reserved id " - f"'{self.step_id}'. Please use a different id for your step." - ) - - -class MissingElseBranchException(RasaException): - """Raised when a flow step is missing an else branch.""" - - def __init__(self, step_id: str, flow_id: str) -> None: - """Initializes the exception.""" - self.step_id = step_id - self.flow_id = flow_id - - def __str__(self) -> str: - """Return a string representation of the exception.""" - return ( - f"Step '{self.step_id}' in flow '{self.flow_id}' is missing an `else` " - f"branch. If a steps `next` statement contains an `if` it always " - f"also needs an `else` branch. Please add the missing `else` branch." - ) - - -class NoNextAllowedForLinkException(RasaException): - """Raised when a flow step has a next link but is not allowed to have one.""" - - def __init__(self, step_id: str, flow_id: str) -> None: - """Initializes the exception.""" - self.step_id = step_id - self.flow_id = flow_id - - def __str__(self) -> str: - """Return a string representation of the exception.""" - return ( - f"Link step '{self.step_id}' in flow '{self.flow_id}' has a `next` but " - f"as a link step is not allowed to have one." - ) - - -class UnresolvedFlowStepIdException(RasaException): - """Raised when a flow step is referenced, but its id can not be resolved.""" - - def __init__( - self, step_id: str, flow_id: str, referenced_from_step_id: Optional[str] - ) -> None: - """Initializes the exception.""" - self.step_id = step_id - self.flow_id = flow_id - self.referenced_from_step_id = referenced_from_step_id - - def __str__(self) -> str: - """Return a string representation of the exception.""" - if self.referenced_from_step_id: - exception_message = ( - f"Step with id '{self.step_id}' could not be resolved. " - f"'Step '{self.referenced_from_step_id}' in flow '{self.flow_id}' " - f"referenced this step but it does not exist. " - ) - else: - exception_message = ( - f"Step '{self.step_id}' in flow '{self.flow_id}' can not be resolved. " - ) - - return exception_message + ( - "Please make sure that the step is defined in the same flow." - ) - - -class UnresolvedFlowException(RasaException): - """Raised when a flow is referenced, but its id cannot be resolved.""" - - def __init__(self, flow_id: str) -> None: - """Initializes the exception.""" - self.flow_id = flow_id - - def __str__(self) -> str: - """Return a string representation of the exception.""" - return ( - f"Flow '{self.flow_id}' can not be resolved. " - f"Please make sure that the flow is defined." - ) - - -class EmptyStepSequenceException(RasaException): - """Raised when an empty step sequence is encountered.""" - - def __init__(self, flow_id: str, step_id: str) -> None: - """Initializes the exception.""" - self.flow_id = flow_id - self.step_id = step_id - - def __str__(self) -> str: - """Return a string representation of the exception.""" - if not self.flow_id: - return "Encountered an empty step sequence." - else: - return f"Encountered an empty step sequence in flow '{self.flow_id}'." - - -class EmptyFlowException(RasaException): - """Raised when a flow is completely empty.""" - - def __init__(self, flow_id: str) -> None: - """Initializes the exception.""" - self.flow_id = flow_id - - def __str__(self) -> str: - """Return a string representation of the exception.""" - return f"Flow '{self.flow_id}' does not have any steps." diff --git a/rasa/shared/core/flows/validation.py b/rasa/shared/core/flows/validation.py index 5c1501a2e16b..626ea6f9159f 100644 --- a/rasa/shared/core/flows/validation.py +++ b/rasa/shared/core/flows/validation.py @@ -1,15 +1,7 @@ +from __future__ import annotations + from typing import Optional, Set, Text -from rasa.shared.core.flows.exceptions import ( - EmptyFlowException, - EmptyStepSequenceException, - ReservedFlowStepIdException, - MissingElseBranchException, - NoNextAllowedForLinkException, - MissingNextLinkException, - UnresolvedFlowStepIdException, - UnreachableFlowStepException, -) from rasa.shared.core.flows.flow_step import ( BranchBasedLink, DEFAULT_STEPS, @@ -20,6 +12,162 @@ FlowStep, ) from rasa.shared.core.flows.flow import Flow +from rasa.shared.exceptions import RasaException + + +class UnreachableFlowStepException(RasaException): + """Raised when a flow step is unreachable.""" + + def __init__(self, step_id: str, flow_id: str) -> None: + """Initializes the exception.""" + self.step_id = step_id + self.flow_id = flow_id + + def __str__(self) -> str: + """Return a string representation of the exception.""" + return ( + f"Step '{self.step_id}' in flow '{self.flow_id}' can not be reached " + f"from the start step. Please make sure that all steps can be reached " + f"from the start step, e.g. by " + f"checking that another step points to this step." + ) + + +class MissingNextLinkException(RasaException): + """Raised when a flow step is missing a next link.""" + + def __init__(self, step_id: str, flow_id: str) -> None: + """Initializes the exception.""" + self.step_id = step_id + self.flow_id = flow_id + + def __str__(self) -> str: + """Return a string representation of the exception.""" + return ( + f"Step '{self.step_id}' in flow '{self.flow_id}' is missing a `next`. " + f"As a last step of a branch, it is required to have one. " + ) + + +class ReservedFlowStepIdException(RasaException): + """Raised when a flow step is using a reserved id.""" + + def __init__(self, step_id: str, flow_id: str) -> None: + """Initializes the exception.""" + self.step_id = step_id + self.flow_id = flow_id + + def __str__(self) -> str: + """Return a string representation of the exception.""" + return ( + f"Step '{self.step_id}' in flow '{self.flow_id}' is using the reserved id " + f"'{self.step_id}'. Please use a different id for your step." + ) + + +class MissingElseBranchException(RasaException): + """Raised when a flow step is missing an else branch.""" + + def __init__(self, step_id: str, flow_id: str) -> None: + """Initializes the exception.""" + self.step_id = step_id + self.flow_id = flow_id + + def __str__(self) -> str: + """Return a string representation of the exception.""" + return ( + f"Step '{self.step_id}' in flow '{self.flow_id}' is missing an `else` " + f"branch. If a steps `next` statement contains an `if` it always " + f"also needs an `else` branch. Please add the missing `else` branch." + ) + + +class NoNextAllowedForLinkException(RasaException): + """Raised when a flow step has a next link but is not allowed to have one.""" + + def __init__(self, step_id: str, flow_id: str) -> None: + """Initializes the exception.""" + self.step_id = step_id + self.flow_id = flow_id + + def __str__(self) -> str: + """Return a string representation of the exception.""" + return ( + f"Link step '{self.step_id}' in flow '{self.flow_id}' has a `next` but " + f"as a link step is not allowed to have one." + ) + + +class UnresolvedFlowStepIdException(RasaException): + """Raised when a flow step is referenced, but its id can not be resolved.""" + + def __init__( + self, step_id: str, flow_id: str, referenced_from_step_id: Optional[str] + ) -> None: + """Initializes the exception.""" + self.step_id = step_id + self.flow_id = flow_id + self.referenced_from_step_id = referenced_from_step_id + + def __str__(self) -> str: + """Return a string representation of the exception.""" + if self.referenced_from_step_id: + exception_message = ( + f"Step with id '{self.step_id}' could not be resolved. " + f"'Step '{self.referenced_from_step_id}' in flow '{self.flow_id}' " + f"referenced this step but it does not exist. " + ) + else: + exception_message = ( + f"Step '{self.step_id}' in flow '{self.flow_id}' can not be resolved. " + ) + + return exception_message + ( + "Please make sure that the step is defined in the same flow." + ) + + +class UnresolvedFlowException(RasaException): + """Raised when a flow is referenced, but its id cannot be resolved.""" + + def __init__(self, flow_id: str) -> None: + """Initializes the exception.""" + self.flow_id = flow_id + + def __str__(self) -> str: + """Return a string representation of the exception.""" + return ( + f"Flow '{self.flow_id}' can not be resolved. " + f"Please make sure that the flow is defined." + ) + + +class EmptyStepSequenceException(RasaException): + """Raised when an empty step sequence is encountered.""" + + def __init__(self, flow_id: str, step_id: str) -> None: + """Initializes the exception.""" + self.flow_id = flow_id + self.step_id = step_id + + def __str__(self) -> str: + """Return a string representation of the exception.""" + if not self.flow_id: + return "Encountered an empty step sequence." + else: + return f"Encountered an empty step sequence in flow '{self.flow_id}'." + + +class EmptyFlowException(RasaException): + """Raised when a flow is completely empty.""" + + def __init__(self, flow_id: str) -> None: + """Initializes the exception.""" + self.flow_id = flow_id + + def __str__(self) -> str: + """Return a string representation of the exception.""" + return f"Flow '{self.flow_id}' does not have any steps." def validate_flow(flow: Flow) -> None: diff --git a/tests/core/flows/test_flow.py b/tests/core/flows/test_flow.py index 684a1c5fce5a..b293bdb791a4 100644 --- a/tests/core/flows/test_flow.py +++ b/tests/core/flows/test_flow.py @@ -1,12 +1,12 @@ import pytest -from rasa.shared.core.flows.exceptions import ( +from rasa.shared.core.flows.flow import Flow +from rasa.shared.core.flows.flows_list import FlowsList +from rasa.shared.core.flows.validation import ( + validate_flow, EmptyStepSequenceException, EmptyFlowException, ) -from rasa.shared.core.flows.flow import Flow -from rasa.shared.core.flows.flows_list import FlowsList -from rasa.shared.core.flows.validation import validate_flow from rasa.shared.importers.importer import FlowSyncImporter from tests.utilities import flows_from_str From 068ea2cc7d0f051fbbdef42a0fd866e220321639 Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Wed, 25 Oct 2023 17:26:59 +0200 Subject: [PATCH 17/31] moved file testing into io file --- rasa/shared/core/flows/utils.py | 22 ---------------------- rasa/shared/core/flows/yaml_flows_io.py | 24 ++++++++++++++++++++++-- rasa/shared/importers/rasa.py | 5 +++-- 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/rasa/shared/core/flows/utils.py b/rasa/shared/core/flows/utils.py index 250efb93720c..b28b04f64312 100644 --- a/rasa/shared/core/flows/utils.py +++ b/rasa/shared/core/flows/utils.py @@ -1,25 +1,3 @@ -from pathlib import Path -from typing import Text, Union -import rasa.shared.data -import rasa.shared.utils.io -KEY_FLOWS = "flows" -def is_flows_file(file_path: Union[Text, Path]) -> bool: - """Check if file contains Flow training data. - - Args: - file_path: Path of the file to check. - - Returns: - `True` in case the file is a flows YAML training data file, - `False` otherwise. - - Raises: - YamlException: if the file seems to be a YAML file (extension) but - can not be read / parsed. - """ - return rasa.shared.data.is_likely_yaml_file( - file_path - ) and rasa.shared.utils.io.is_key_in_yaml(file_path, KEY_FLOWS) diff --git a/rasa/shared/core/flows/yaml_flows_io.py b/rasa/shared/core/flows/yaml_flows_io.py index 21cf8e5a335d..76dd0748016d 100644 --- a/rasa/shared/core/flows/yaml_flows_io.py +++ b/rasa/shared/core/flows/yaml_flows_io.py @@ -2,8 +2,8 @@ from pathlib import Path from typing import List, Text, Union -from rasa.shared.core.flows.utils import KEY_FLOWS - +import rasa.shared +import rasa.shared.data import rasa.shared.utils.io import rasa.shared.utils.validation from rasa.shared.exceptions import YamlException @@ -12,6 +12,7 @@ from rasa.shared.core.flows.flows_list import FlowsList FLOWS_SCHEMA_FILE = "shared/core/flows/flows_yaml_schema.json" +KEY_FLOWS = "flows" class YAMLFlowsReader: @@ -101,3 +102,22 @@ def dump(flows: List[Flow], filename: Union[Text, Path]) -> None: def flows_from_str(yaml_str: str) -> FlowsList: """Reads flows from a YAML string.""" return YAMLFlowsReader.read_from_string(textwrap.dedent(yaml_str)) + + +def is_flows_file(file_path: Union[Text, Path]) -> bool: + """Check if file contains Flow training data. + + Args: + file_path: Path of the file to check. + + Returns: + `True` in case the file is a flows YAML training data file, + `False` otherwise. + + Raises: + YamlException: if the file seems to be a YAML file (extension) but + can not be read / parsed. + """ + return rasa.shared.data.is_likely_yaml_file( + file_path + ) and rasa.shared.utils.io.is_key_in_yaml(file_path, KEY_FLOWS) diff --git a/rasa/shared/importers/rasa.py b/rasa/shared/importers/rasa.py index cbf3a896cf03..f592ae9c07ac 100644 --- a/rasa/shared/importers/rasa.py +++ b/rasa/shared/importers/rasa.py @@ -1,6 +1,8 @@ import logging import os from typing import Dict, List, Optional, Text, Union + +import rasa.shared.core.flows.yaml_flows_io from rasa.shared.core.flows.flows_list import FlowsList import rasa.shared.data @@ -11,7 +13,6 @@ from rasa.shared.importers.importer import TrainingDataImporter from rasa.shared.nlu.training_data.training_data import TrainingData from rasa.shared.core.domain import InvalidDomain, Domain -import rasa.shared.core.flows.utils from rasa.shared.core.training_data.story_reader.yaml_story_reader import ( YAMLStoryReader, ) @@ -38,7 +39,7 @@ def __init__( training_data_paths, YAMLStoryReader.is_stories_file ) self._flow_files = rasa.shared.data.get_data_files( - training_data_paths, rasa.shared.core.flows.utils.is_flows_file + training_data_paths, rasa.shared.core.flows.yaml_flows_io.is_flows_file ) self._conversation_test_files = rasa.shared.data.get_data_files( training_data_paths, YAMLStoryReader.is_test_stories_file From 0db22f4cbca5839a1af36ab41819e95b7c397812 Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Thu, 26 Oct 2023 08:16:29 +0200 Subject: [PATCH 18/31] removed empty utils file --- rasa/shared/core/flows/utils.py | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 rasa/shared/core/flows/utils.py diff --git a/rasa/shared/core/flows/utils.py b/rasa/shared/core/flows/utils.py deleted file mode 100644 index b28b04f64312..000000000000 --- a/rasa/shared/core/flows/utils.py +++ /dev/null @@ -1,3 +0,0 @@ - - - From 32b8d72a313fc978d1ffbaa91c155a2157bd2f76 Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Thu, 26 Oct 2023 08:58:10 +0200 Subject: [PATCH 19/31] Pulled big flow step, flow step sequence and flow step links file apart --- rasa/core/actions/action_clean_stack.py | 3 +- rasa/core/policies/flow_policy.py | 28 +- .../commands/correct_slots_command.py | 4 +- .../generator/llm_command_generator.py | 2 +- .../dialogue_understanding/patterns/cancel.py | 3 +- .../patterns/collect_information.py | 2 +- .../patterns/correction.py | 6 +- .../processor/command_processor.py | 4 +- .../stack/frames/flow_stack_frame.py | 3 +- rasa/dialogue_understanding/stack/utils.py | 3 +- rasa/shared/core/flows/flow.py | 22 +- rasa/shared/core/flows/flow_step.py | 906 +----------------- rasa/shared/core/flows/flow_step_links.py | 280 ++++++ rasa/shared/core/flows/flow_step_sequence.py | 54 ++ rasa/shared/core/flows/steps/__init__.py | 10 + rasa/shared/core/flows/steps/action.py | 49 + rasa/shared/core/flows/steps/branch.py | 37 + rasa/shared/core/flows/steps/collect.py | 107 +++ rasa/shared/core/flows/steps/constants.py | 4 + rasa/shared/core/flows/steps/continuation.py | 33 + rasa/shared/core/flows/steps/end.py | 22 + .../core/flows/steps/generate_response.py | 95 ++ rasa/shared/core/flows/steps/internal.py | 33 + rasa/shared/core/flows/steps/link.py | 44 + rasa/shared/core/flows/steps/set_slots.py | 49 + rasa/shared/core/flows/steps/start.py | 32 + rasa/shared/core/flows/steps/user_message.py | 141 +++ rasa/shared/core/flows/validation.py | 9 +- rasa/validator.py | 12 +- .../test_handle_code_change_command.py | 7 +- .../generator/test_llm_command_generator.py | 4 +- .../stack/frames/test_flow_frame.py | 8 +- 32 files changed, 1068 insertions(+), 948 deletions(-) create mode 100644 rasa/shared/core/flows/flow_step_links.py create mode 100644 rasa/shared/core/flows/flow_step_sequence.py create mode 100644 rasa/shared/core/flows/steps/__init__.py create mode 100644 rasa/shared/core/flows/steps/action.py create mode 100644 rasa/shared/core/flows/steps/branch.py create mode 100644 rasa/shared/core/flows/steps/collect.py create mode 100644 rasa/shared/core/flows/steps/constants.py create mode 100644 rasa/shared/core/flows/steps/continuation.py create mode 100644 rasa/shared/core/flows/steps/end.py create mode 100644 rasa/shared/core/flows/steps/generate_response.py create mode 100644 rasa/shared/core/flows/steps/internal.py create mode 100644 rasa/shared/core/flows/steps/link.py create mode 100644 rasa/shared/core/flows/steps/set_slots.py create mode 100644 rasa/shared/core/flows/steps/start.py create mode 100644 rasa/shared/core/flows/steps/user_message.py diff --git a/rasa/core/actions/action_clean_stack.py b/rasa/core/actions/action_clean_stack.py index edc3044b4b6a..659ccc7189b9 100644 --- a/rasa/core/actions/action_clean_stack.py +++ b/rasa/core/actions/action_clean_stack.py @@ -14,7 +14,8 @@ from rasa.shared.core.constants import ACTION_CLEAN_STACK, DIALOGUE_STACK_SLOT from rasa.shared.core.domain import Domain from rasa.shared.core.events import Event, SlotSet -from rasa.shared.core.flows.flow_step import ContinueFlowStep, END_STEP +from rasa.shared.core.flows.steps.constants import END_STEP +from rasa.shared.core.flows.steps.continuation import ContinueFlowStep from rasa.shared.core.trackers import DialogueStateTracker diff --git a/rasa/core/policies/flow_policy.py b/rasa/core/policies/flow_policy.py index c7fb680a1aad..aa7fa5429e6a 100644 --- a/rasa/core/policies/flow_policy.py +++ b/rasa/core/policies/flow_policy.py @@ -45,25 +45,31 @@ ) from rasa.shared.core.events import Event, SlotSet from rasa.shared.core.flows.flow_step import ( - END_STEP, - ActionFlowStep, - BranchFlowStep, - ContinueFlowStep, - ElseFlowLink, FlowStep, - GenerateResponseFlowStep, +) +from rasa.shared.core.flows.flow_step_links import ( IfFlowLink, + ElseFlowLink, + StaticFlowLink, +) +from rasa.shared.core.flows.steps.constants import END_STEP +from rasa.shared.core.flows.steps.continuation import ContinueFlowStep +from rasa.shared.core.flows.steps.set_slots import SetSlotsFlowStep +from rasa.shared.core.flows.steps.collect import ( SlotRejection, + CollectInformationFlowStep, +) +from rasa.shared.core.flows.steps.generate_response import GenerateResponseFlowStep +from rasa.shared.core.flows.steps.user_message import ( StepThatCanStartAFlow, UserMessageStep, - LinkFlowStep, - SetSlotsFlowStep, - CollectInformationFlowStep, - StaticFlowLink, ) +from rasa.shared.core.flows.steps.link import LinkFlowStep +from rasa.shared.core.flows.steps.branch import BranchFlowStep +from rasa.shared.core.flows.steps.action import ActionFlowStep from rasa.shared.core.flows.flow import Flow from rasa.shared.core.flows.flows_list import FlowsList -from rasa.shared.core.flows.flow_step import EndFlowStep +from rasa.shared.core.flows.steps.end import EndFlowStep from rasa.core.featurizers.tracker_featurizers import TrackerFeaturizer from rasa.core.policies.policy import Policy, PolicyPrediction from rasa.engine.graph import ExecutionContext diff --git a/rasa/dialogue_understanding/commands/correct_slots_command.py b/rasa/dialogue_understanding/commands/correct_slots_command.py index a1f13dcdb648..f7b121bd11f7 100644 --- a/rasa/dialogue_understanding/commands/correct_slots_command.py +++ b/rasa/dialogue_understanding/commands/correct_slots_command.py @@ -16,7 +16,9 @@ UserFlowStackFrame, ) from rasa.shared.core.events import Event -from rasa.shared.core.flows.flow_step import END_STEP, ContinueFlowStep, FlowStep +from rasa.shared.core.flows.flow_step import FlowStep +from rasa.shared.core.flows.steps.constants import END_STEP +from rasa.shared.core.flows.steps.continuation import ContinueFlowStep from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker import rasa.dialogue_understanding.stack.utils as utils diff --git a/rasa/dialogue_understanding/generator/llm_command_generator.py b/rasa/dialogue_understanding/generator/llm_command_generator.py index 57e105c33ef7..81195adf5a40 100644 --- a/rasa/dialogue_understanding/generator/llm_command_generator.py +++ b/rasa/dialogue_understanding/generator/llm_command_generator.py @@ -25,8 +25,8 @@ from rasa.engine.storage.storage import ModelStorage from rasa.shared.core.flows.flow_step import ( FlowStep, - CollectInformationFlowStep, ) +from rasa.shared.core.flows.steps.collect import CollectInformationFlowStep from rasa.shared.core.flows.flow import Flow from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker diff --git a/rasa/dialogue_understanding/patterns/cancel.py b/rasa/dialogue_understanding/patterns/cancel.py index ecc3f5b17057..ce230157dc7b 100644 --- a/rasa/dialogue_understanding/patterns/cancel.py +++ b/rasa/dialogue_understanding/patterns/cancel.py @@ -18,7 +18,8 @@ from rasa.shared.core.constants import ACTION_CANCEL_FLOW from rasa.shared.core.domain import Domain from rasa.shared.core.events import Event -from rasa.shared.core.flows.flow_step import END_STEP, ContinueFlowStep +from rasa.shared.core.flows.steps.constants import END_STEP +from rasa.shared.core.flows.steps.continuation import ContinueFlowStep from rasa.shared.core.trackers import DialogueStateTracker diff --git a/rasa/dialogue_understanding/patterns/collect_information.py b/rasa/dialogue_understanding/patterns/collect_information.py index fa0388014279..5a3d19526221 100644 --- a/rasa/dialogue_understanding/patterns/collect_information.py +++ b/rasa/dialogue_understanding/patterns/collect_information.py @@ -5,7 +5,7 @@ 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_step import SlotRejection +from rasa.shared.core.flows.steps.collect import SlotRejection FLOW_PATTERN_COLLECT_INFORMATION = ( RASA_DEFAULT_FLOW_PATTERN_PREFIX + "collect_information" diff --git a/rasa/dialogue_understanding/patterns/correction.py b/rasa/dialogue_understanding/patterns/correction.py index 00aa4d127425..e909969360e9 100644 --- a/rasa/dialogue_understanding/patterns/correction.py +++ b/rasa/dialogue_understanding/patterns/correction.py @@ -7,9 +7,7 @@ ) from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack from rasa.shared.constants import RASA_DEFAULT_FLOW_PATTERN_PREFIX -from rasa.shared.core.flows.flow_step import ( - START_STEP, -) +from rasa.shared.core.flows.steps.constants import START_STEP, END_STEP from rasa.shared.core.trackers import ( DialogueStateTracker, ) @@ -30,7 +28,7 @@ SlotSet, ) from rasa.core.nlg import NaturalLanguageGenerator -from rasa.shared.core.flows.flow_step import END_STEP, ContinueFlowStep +from rasa.shared.core.flows.steps.continuation import ContinueFlowStep structlogger = structlog.get_logger() diff --git a/rasa/dialogue_understanding/processor/command_processor.py b/rasa/dialogue_understanding/processor/command_processor.py index a25e071d6369..fa8346e26f00 100644 --- a/rasa/dialogue_understanding/processor/command_processor.py +++ b/rasa/dialogue_understanding/processor/command_processor.py @@ -28,9 +28,7 @@ ) from rasa.shared.core.constants import FLOW_HASHES_SLOT from rasa.shared.core.events import Event, SlotSet -from rasa.shared.core.flows.flow_step import ( - CollectInformationFlowStep, -) +from rasa.shared.core.flows.steps.collect import CollectInformationFlowStep from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker from rasa.shared.nlu.constants import COMMANDS diff --git a/rasa/dialogue_understanding/stack/frames/flow_stack_frame.py b/rasa/dialogue_understanding/stack/frames/flow_stack_frame.py index 6a662ce1199f..f05bdefe2932 100644 --- a/rasa/dialogue_understanding/stack/frames/flow_stack_frame.py +++ b/rasa/dialogue_understanding/stack/frames/flow_stack_frame.py @@ -4,7 +4,8 @@ from typing import Any, Dict, Optional from rasa.dialogue_understanding.stack.frames import DialogueStackFrame -from rasa.shared.core.flows.flow_step import START_STEP, FlowStep +from rasa.shared.core.flows.flow_step import FlowStep +from rasa.shared.core.flows.steps.constants import START_STEP from rasa.shared.core.flows.flow import Flow from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.exceptions import RasaException diff --git a/rasa/dialogue_understanding/stack/utils.py b/rasa/dialogue_understanding/stack/utils.py index 1c83cfc51ea7..80fc90459689 100644 --- a/rasa/dialogue_understanding/stack/utils.py +++ b/rasa/dialogue_understanding/stack/utils.py @@ -5,7 +5,8 @@ from rasa.dialogue_understanding.stack.frames import BaseFlowStackFrame from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack from rasa.dialogue_understanding.stack.frames import UserFlowStackFrame -from rasa.shared.core.flows.flow_step import END_STEP, ContinueFlowStep +from rasa.shared.core.flows.steps.constants import END_STEP +from rasa.shared.core.flows.steps.continuation import ContinueFlowStep from rasa.shared.core.flows.flows_list import FlowsList diff --git a/rasa/shared/core/flows/flow.py b/rasa/shared/core/flows/flow.py index 1296c4555c0c..1bc0d5a58c73 100644 --- a/rasa/shared/core/flows/flow.py +++ b/rasa/shared/core/flows/flow.py @@ -4,21 +4,23 @@ from functools import cached_property from typing import Text, Optional, Dict, Any, List, Set -import rasa.shared +import rasa.shared.utils.io from rasa.shared.constants import RASA_DEFAULT_FLOW_PATTERN_PREFIX from rasa.shared.core.flows.flow_step import ( - StepSequence, FlowStep, - LinkFlowStep, - StaticFlowLink, - END_STEP, - START_STEP, - StartFlowStep, - EndFlowStep, +) +from rasa.shared.core.flows.flow_step_links import StaticFlowLink +from rasa.shared.core.flows.steps.continuation import ContinueFlowStep +from rasa.shared.core.flows.steps.constants import ( CONTINUE_STEP_PREFIX, - ContinueFlowStep, - CollectInformationFlowStep, + START_STEP, + END_STEP, ) +from rasa.shared.core.flows.steps.end import EndFlowStep +from rasa.shared.core.flows.steps.start import StartFlowStep +from rasa.shared.core.flows.steps.collect import CollectInformationFlowStep +from rasa.shared.core.flows.steps.link import LinkFlowStep +from rasa.shared.core.flows.flow_step_sequence import StepSequence @dataclass diff --git a/rasa/shared/core/flows/flow_step.py b/rasa/shared/core/flows/flow_step.py index 06a0e8c9f359..8ad5241b92b4 100644 --- a/rasa/shared/core/flows/flow_step.py +++ b/rasa/shared/core/flows/flow_step.py @@ -1,84 +1,22 @@ from __future__ import annotations +from typing import TYPE_CHECKING from dataclasses import dataclass from typing import ( Any, Dict, Generator, - List, Optional, - Protocol, Set, Text, - Union, - runtime_checkable, ) import structlog -from rasa.shared.core.trackers import DialogueStateTracker -from rasa.shared.constants import UTTER_PREFIX -from rasa.shared.nlu.constants import ENTITY_ATTRIBUTE_TYPE, INTENT_NAME_KEY - -from rasa.shared.utils.llm import ( - DEFAULT_OPENAI_GENERATE_MODEL_NAME, - DEFAULT_OPENAI_TEMPERATURE, -) +if TYPE_CHECKING: + from rasa.shared.core.flows.flow_step_links import FlowLinks structlogger = structlog.get_logger() -START_STEP = "START" - -END_STEP = "END" - -DEFAULT_STEPS = {END_STEP, START_STEP} - - -@dataclass -class StepSequence: - child_steps: List[FlowStep] - - @staticmethod - def from_json(steps_config: List[Dict[Text, Any]]) -> StepSequence: - """Used to read steps from parsed YAML. - - Args: - steps_config: The parsed YAML as a dictionary. - - Returns: - The parsed steps. - """ - - flow_steps: List[FlowStep] = [step_from_json(config) for config in steps_config] - - return StepSequence(child_steps=flow_steps) - - def as_json(self) -> List[Dict[Text, Any]]: - """Returns the steps as a dictionary. - - Returns: - The steps as a dictionary. - """ - return [ - step.as_json() - for step in self.child_steps - if not isinstance(step, InternalFlowStep) - ] - - @property - def steps(self) -> List[FlowStep]: - """Returns the steps of the flow.""" - return [ - step - for child_step in self.child_steps - for step in child_step.steps_in_tree() - ] - - def first(self) -> Optional[FlowStep]: - """Returns the first step of the sequence.""" - if len(self.child_steps) == 0: - return None - return self.child_steps[0] - def step_from_json(flow_step_config: Dict[Text, Any]) -> FlowStep: """Used to read flow steps from parsed YAML. @@ -89,6 +27,16 @@ def step_from_json(flow_step_config: Dict[Text, Any]) -> FlowStep: Returns: The parsed flow step. """ + from rasa.shared.core.flows.steps import ( + ActionFlowStep, + UserMessageStep, + CollectInformationFlowStep, + LinkFlowStep, + SetSlotsFlowStep, + GenerateResponseFlowStep, + BranchFlowStep, + ) + if "action" in flow_step_config: return ActionFlowStep.from_json(flow_step_config) if "intent" in flow_step_config: @@ -117,7 +65,7 @@ class FlowStep: """The description of the flow step.""" metadata: Dict[Text, Any] """Additional, unstructured information about this flow step.""" - next: "FlowLinks" + next: FlowLinks """The next steps of the flow step.""" @classmethod @@ -130,6 +78,8 @@ def _from_json(cls, flow_step_config: Dict[Text, Any]) -> FlowStep: Returns: The parsed flow step. """ + from rasa.shared.core.flows.flow_step_links import FlowLinks + return FlowStep( # the idx is set later once the flow is created that contains # this step @@ -176,827 +126,3 @@ def default_id_postfix(self) -> str: def utterances(self) -> Set[str]: """Return all the utterances used in this step""" return set() - - -class InternalFlowStep(FlowStep): - """Represents the configuration of a built-in flow step. - - Built in flow steps are required to manage the lifecycle of a - flow and are not intended to be used by users. - """ - - @classmethod - def from_json(cls, flow_step_config: Dict[Text, Any]) -> ActionFlowStep: - """Used to read flow steps from parsed JSON. - - Args: - flow_step_config: The parsed JSON as a dictionary. - - Returns: - The parsed flow step. - """ - raise ValueError("A start step cannot be parsed.") - - def as_json(self) -> Dict[Text, Any]: - """Returns the flow step as a dictionary. - - Returns: - The flow step as a dictionary. - """ - raise ValueError("A start step cannot be dumped.") - - -@dataclass -class StartFlowStep(InternalFlowStep): - """Represents the configuration of a start flow step.""" - - def __init__(self, start_step_id: Optional[Text]) -> None: - """Initializes a start flow step. - - Args: - start_step: The step to start the flow from. - """ - if start_step_id is not None: - links: List[FlowLink] = [StaticFlowLink(start_step_id)] - else: - links = [] - - super().__init__( - idx=0, - custom_id=START_STEP, - description=None, - metadata={}, - next=FlowLinks(links=links), - ) - - -@dataclass -class EndFlowStep(InternalFlowStep): - """Represents the configuration of an end to a flow.""" - - def __init__(self) -> None: - """Initializes an end flow step.""" - super().__init__( - idx=0, - custom_id=END_STEP, - description=None, - metadata={}, - next=FlowLinks(links=[]), - ) - - -CONTINUE_STEP_PREFIX = "NEXT:" - - -@dataclass -class ContinueFlowStep(InternalFlowStep): - """Represents the configuration of a continue-step flow step.""" - - def __init__(self, next: str) -> None: - """Initializes a continue-step flow step.""" - super().__init__( - idx=0, - custom_id=CONTINUE_STEP_PREFIX + next, - description=None, - metadata={}, - # The continue step links to the step that should be continued. - # The flow policy in a sense only "runs" the logic of a step - # when it transitions to that step, once it is there it will use - # the next link to transition to the next step. This means that - # if we want to "re-run" a step, we need to link to it again. - # This is why the continue step links to the step that should be - # continued. - next=FlowLinks(links=[StaticFlowLink(next)]), - ) - - @staticmethod - def continue_step_for_id(step_id: str) -> str: - return CONTINUE_STEP_PREFIX + step_id - - -@dataclass -class ActionFlowStep(FlowStep): - """Represents the configuration of an action flow step.""" - - action: Text - """The action of the flow step.""" - - @classmethod - def from_json(cls, flow_step_config: Dict[Text, Any]) -> ActionFlowStep: - """Used to read flow steps from parsed YAML. - - Args: - flow_step_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow step. - """ - base = super()._from_json(flow_step_config) - return ActionFlowStep( - action=flow_step_config.get("action", ""), - **base.__dict__, - ) - - def as_json(self) -> Dict[Text, Any]: - """Returns the flow step as a dictionary. - - Returns: - The flow step as a dictionary. - """ - dump = super().as_json() - dump["action"] = self.action - return dump - - def default_id_postfix(self) -> str: - return self.action - - @property - def utterances(self) -> Set[str]: - """Return all the utterances used in this step""" - return {self.action} if self.action.startswith(UTTER_PREFIX) else set() - - -@dataclass -class BranchFlowStep(FlowStep): - """Represents the configuration of a branch flow step.""" - - @classmethod - def from_json(cls, flow_step_config: Dict[Text, Any]) -> BranchFlowStep: - """Used to read flow steps from parsed YAML. - - Args: - flow_step_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow step. - """ - base = super()._from_json(flow_step_config) - return BranchFlowStep(**base.__dict__) - - def as_json(self) -> Dict[Text, Any]: - """Returns the flow step as a dictionary. - - Returns: - The flow step as a dictionary. - """ - dump = super().as_json() - return dump - - def default_id_postfix(self) -> str: - """Returns the default id postfix of the flow step.""" - return "branch" - - -@dataclass -class LinkFlowStep(FlowStep): - """Represents the configuration of a link flow step.""" - - link: Text - """The link of the flow step.""" - - @classmethod - def from_json(cls, flow_step_config: Dict[Text, Any]) -> LinkFlowStep: - """Used to read flow steps from parsed YAML. - - Args: - flow_step_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow step. - """ - base = super()._from_json(flow_step_config) - return LinkFlowStep( - link=flow_step_config.get("link", ""), - **base.__dict__, - ) - - def as_json(self) -> Dict[Text, Any]: - """Returns the flow step as a dictionary. - - Returns: - The flow step as a dictionary. - """ - dump = super().as_json() - dump["link"] = self.link - return dump - - def default_id_postfix(self) -> str: - """Returns the default id postfix of the flow step.""" - return f"link_{self.link}" - - -@dataclass -class TriggerCondition: - """Represents the configuration of a trigger condition.""" - - intent: Text - """The intent to trigger the flow.""" - entities: List[Text] - """The entities to trigger the flow.""" - - def is_triggered(self, intent: Text, entities: List[Text]) -> bool: - """Check if condition is triggered by the given intent and entities. - - Args: - intent: The intent to check. - entities: The entities to check. - - Returns: - Whether the trigger condition is triggered by the given intent and entities. - """ - if self.intent != intent: - return False - if len(self.entities) == 0: - return True - return all(entity in entities for entity in self.entities) - - -@runtime_checkable -class StepThatCanStartAFlow(Protocol): - """Represents a step that can start a flow.""" - - def is_triggered(self, tracker: DialogueStateTracker) -> bool: - """Check if a flow should be started for the tracker - - Args: - tracker: The tracker to check. - - Returns: - Whether a flow should be started for the tracker. - """ - ... - - -@dataclass -class UserMessageStep(FlowStep, StepThatCanStartAFlow): - """Represents the configuration of an intent flow step.""" - - trigger_conditions: List[TriggerCondition] - """The trigger conditions of the flow step.""" - - @classmethod - def from_json(cls, flow_step_config: Dict[Text, Any]) -> UserMessageStep: - """Used to read flow steps from parsed YAML. - - Args: - flow_step_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow step. - """ - base = super()._from_json(flow_step_config) - - trigger_conditions = [] - if "intent" in flow_step_config: - trigger_conditions.append( - TriggerCondition( - intent=flow_step_config["intent"], - entities=flow_step_config.get("entities", []), - ) - ) - elif "or" in flow_step_config: - for trigger_condition in flow_step_config["or"]: - trigger_conditions.append( - TriggerCondition( - intent=trigger_condition.get("intent", ""), - entities=trigger_condition.get("entities", []), - ) - ) - - return UserMessageStep( - trigger_conditions=trigger_conditions, - **base.__dict__, - ) - - def as_json(self) -> Dict[Text, Any]: - """Returns the flow step as a dictionary. - - Returns: - The flow step as a dictionary. - """ - dump = super().as_json() - - if len(self.trigger_conditions) == 1: - dump["intent"] = self.trigger_conditions[0].intent - if self.trigger_conditions[0].entities: - dump["entities"] = self.trigger_conditions[0].entities - elif len(self.trigger_conditions) > 1: - dump["or"] = [ - { - "intent": trigger_condition.intent, - "entities": trigger_condition.entities, - } - for trigger_condition in self.trigger_conditions - ] - - return dump - - def is_triggered(self, tracker: DialogueStateTracker) -> bool: - """Returns whether the flow step is triggered by the given intent and entities. - - Args: - intent: The intent to check. - entities: The entities to check. - - Returns: - Whether the flow step is triggered by the given intent and entities. - """ - if not tracker.latest_message: - return False - - intent: Text = tracker.latest_message.intent.get(INTENT_NAME_KEY, "") - entities: List[Text] = [ - e.get(ENTITY_ATTRIBUTE_TYPE, "") for e in tracker.latest_message.entities - ] - return any( - trigger_condition.is_triggered(intent, entities) - for trigger_condition in self.trigger_conditions - ) - - def default_id_postfix(self) -> str: - """Returns the default id postfix of the flow step.""" - return "intent" - - -DEFAULT_LLM_CONFIG = { - "_type": "openai", - "request_timeout": 5, - "temperature": DEFAULT_OPENAI_TEMPERATURE, - "model_name": DEFAULT_OPENAI_GENERATE_MODEL_NAME, -} - - -@dataclass -class GenerateResponseFlowStep(FlowStep): - """Represents the configuration of a step prompting an LLM.""" - - generation_prompt: Text - """The prompt template of the flow step.""" - llm_config: Optional[Dict[Text, Any]] = None - """The LLM configuration of the flow step.""" - - @classmethod - def from_json(cls, flow_step_config: Dict[Text, Any]) -> GenerateResponseFlowStep: - """Used to read flow steps from parsed YAML. - - Args: - flow_step_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow step. - """ - base = super()._from_json(flow_step_config) - return GenerateResponseFlowStep( - generation_prompt=flow_step_config.get("generation_prompt", ""), - llm_config=flow_step_config.get("llm", None), - **base.__dict__, - ) - - def as_json(self) -> Dict[Text, Any]: - """Returns the flow step as a dictionary. - - Returns: - The flow step as a dictionary. - """ - dump = super().as_json() - dump["generation_prompt"] = self.generation_prompt - if self.llm_config: - dump["llm"] = self.llm_config - - return dump - - def generate(self, tracker: DialogueStateTracker) -> Optional[Text]: - """Generates a response for the given tracker. - - Args: - tracker: The tracker to generate a response for. - - Returns: - The generated response. - """ - from rasa.shared.utils.llm import llm_factory, tracker_as_readable_transcript - from jinja2 import Template - - context = { - "history": tracker_as_readable_transcript(tracker, max_turns=5), - "latest_user_message": tracker.latest_message.text - if tracker.latest_message - else "", - } - context.update(tracker.current_slot_values()) - - llm = llm_factory(self.llm_config, DEFAULT_LLM_CONFIG) - prompt = Template(self.generation_prompt).render(context) - - try: - return llm(prompt) - except Exception as e: - # unfortunately, langchain does not wrap LLM exceptions which means - # we have to catch all exceptions here - structlogger.error( - "flow.generate_step.llm.error", error=e, step=self.id, prompt=prompt - ) - return None - - def default_id_postfix(self) -> str: - return "generate" - - -@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: 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 - """Whether to always ask the question even if the slot is already filled.""" - reset_after_flow_ends: bool = True - """Determines whether to reset the slot value at the end of the flow.""" - - @classmethod - def from_json(cls, flow_step_config: Dict[Text, Any]) -> CollectInformationFlowStep: - """Used to read flow steps from parsed YAML. - - Args: - flow_step_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow step. - """ - base = super()._from_json(flow_step_config) - return CollectInformationFlowStep( - collect=flow_step_config["collect"], - utter=flow_step_config.get( - "utter", f"utter_ask_{flow_step_config['collect']}" - ), - ask_before_filling=flow_step_config.get("ask_before_filling", False), - reset_after_flow_ends=flow_step_config.get("reset_after_flow_ends", True), - rejections=[ - SlotRejection.from_dict(rejection) - for rejection in flow_step_config.get("rejections", []) - ], - **base.__dict__, - ) - - def as_json(self) -> Dict[Text, Any]: - """Returns the flow step as a dictionary. - - Returns: - The flow step as a dictionary. - """ - dump = super().as_json() - dump["collect"] = self.collect - dump["utter"] = self.utter - dump["ask_before_filling"] = self.ask_before_filling - dump["reset_after_flow_ends"] = self.reset_after_flow_ends - dump["rejections"] = [rejection.as_dict() for rejection in self.rejections] - - return dump - - def default_id_postfix(self) -> str: - """Returns the default id postfix of the flow step.""" - return f"collect_{self.collect}" - - @property - def utterances(self) -> Set[str]: - """Return all the utterances used in this step""" - return {self.utter} | {r.utter for r in self.rejections} - - -@dataclass -class SetSlotsFlowStep(FlowStep): - """Represents the configuration of a set_slots flow step.""" - - slots: List[Dict[str, Any]] - """Slots to set of the flow step.""" - - @classmethod - def from_json(cls, flow_step_config: Dict[Text, Any]) -> SetSlotsFlowStep: - """Used to read flow steps from parsed YAML. - - Args: - flow_step_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow step. - """ - base = super()._from_json(flow_step_config) - slots = [ - {"key": k, "value": v} - for slot in flow_step_config.get("set_slots", []) - for k, v in slot.items() - ] - return SetSlotsFlowStep( - slots=slots, - **base.__dict__, - ) - - def as_json(self) -> Dict[Text, Any]: - """Returns the flow step as a dictionary. - - Returns: - The flow step as a dictionary. - """ - dump = super().as_json() - dump["set_slots"] = [{slot["key"]: slot["value"]} for slot in self.slots] - return dump - - def default_id_postfix(self) -> str: - """Returns the default id postfix of the flow step.""" - return "set_slots" - - -@dataclass -class FlowLinks: - """Represents the configuration of a list of flow links.""" - - links: List[FlowLink] - - @staticmethod - def from_json(flow_links_config: Union[str, List[Dict[Text, Any]]]) -> FlowLinks: - """Used to read flow links from parsed YAML. - - Args: - flow_links_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow links. - """ - if not flow_links_config: - return FlowLinks(links=[]) - - if isinstance(flow_links_config, str): - return FlowLinks(links=[StaticFlowLink.from_json(flow_links_config)]) - - return FlowLinks( - links=[ - BranchBasedLink.from_json(link_config) - for link_config in flow_links_config - if link_config - ] - ) - - def as_json(self) -> Optional[Union[str, List[Dict[str, Any]]]]: - """Returns the flow links as a dictionary. - - Returns: - The flow links as a dictionary. - """ - if not self.links: - return None - - if len(self.links) == 1 and isinstance(self.links[0], StaticFlowLink): - return self.links[0].as_json() - - return [link.as_json() for link in self.links] - - def no_link_available(self) -> bool: - """Returns whether no link is available.""" - return len(self.links) == 0 - - def steps_in_tree(self) -> Generator[FlowStep, None, None]: - """Returns the steps in the tree of the flow links.""" - for link in self.links: - yield from link.steps_in_tree() - - -class FlowLink: - """Represents a flow link.""" - - @property - def target(self) -> Optional[Text]: - """Returns the target of the flow link. - - Returns: - The target of the flow link. - """ - raise NotImplementedError() - - def as_json(self) -> Any: - """Returns the flow link as a dictionary. - - Returns: - The flow link as a dictionary. - """ - raise NotImplementedError() - - @staticmethod - def from_json(link_config: Any) -> FlowLink: - """Used to read flow links from parsed YAML. - - Args: - link_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow link. - """ - raise NotImplementedError() - - def steps_in_tree(self) -> Generator[FlowStep, None, None]: - """Returns the steps in the tree of the flow link.""" - raise NotImplementedError() - - def child_steps(self) -> List[FlowStep]: - """Returns the child steps of the flow link.""" - raise NotImplementedError() - - -@dataclass -class BranchBasedLink(FlowLink): - target_reference: Union[Text, StepSequence] - """The id of the linked flow.""" - - def steps_in_tree(self) -> Generator[FlowStep, None, None]: - """Returns the steps in the tree of the flow link.""" - if isinstance(self.target_reference, StepSequence): - yield from self.target_reference.steps - - def child_steps(self) -> List[FlowStep]: - """Returns the child steps of the flow link.""" - if isinstance(self.target_reference, StepSequence): - return self.target_reference.child_steps - else: - return [] - - @property - def target(self) -> Optional[Text]: - """Returns the target of the flow link.""" - if isinstance(self.target_reference, StepSequence): - if first := self.target_reference.first(): - return first.id - else: - return None - else: - return self.target_reference - - @staticmethod - def from_json(link_config: Dict[Text, Any]) -> BranchBasedLink: - """Used to read a single flow links from parsed YAML. - - Args: - link_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow link. - """ - if "if" in link_config: - return IfFlowLink.from_json(link_config) - else: - return ElseFlowLink.from_json(link_config) - - -@dataclass -class IfFlowLink(BranchBasedLink): - """Represents the configuration of an if flow link.""" - - condition: Optional[Text] - """The condition of the linked flow.""" - - @staticmethod - def from_json(link_config: Dict[Text, Any]) -> IfFlowLink: - """Used to read flow links from parsed YAML. - - Args: - link_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow link. - """ - if isinstance(link_config["then"], str): - return IfFlowLink( - target_reference=link_config["then"], condition=link_config.get("if") - ) - else: - return IfFlowLink( - target_reference=StepSequence.from_json(link_config["then"]), - condition=link_config.get("if"), - ) - - def as_json(self) -> Dict[Text, Any]: - """Returns the flow link as a dictionary. - - Returns: - The flow link as a dictionary. - """ - return { - "if": self.condition, - "then": self.target_reference.as_json() - if isinstance(self.target_reference, StepSequence) - else self.target_reference, - } - - -@dataclass -class ElseFlowLink(BranchBasedLink): - """Represents the configuration of an else flow link.""" - - @staticmethod - def from_json(link_config: Dict[Text, Any]) -> ElseFlowLink: - """Used to read flow links from parsed YAML. - - Args: - link_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow link. - """ - if isinstance(link_config["else"], str): - return ElseFlowLink(target_reference=link_config["else"]) - else: - return ElseFlowLink( - target_reference=StepSequence.from_json(link_config["else"]) - ) - - def as_json(self) -> Dict[Text, Any]: - """Returns the flow link as a dictionary. - - Returns: - The flow link as a dictionary. - """ - return { - "else": self.target_reference.as_json() - if isinstance(self.target_reference, StepSequence) - else self.target_reference - } - - -@dataclass -class StaticFlowLink(FlowLink): - """Represents the configuration of a static flow link.""" - - target_id: Text - """The id of the linked flow.""" - - @staticmethod - def from_json(link_config: Text) -> StaticFlowLink: - """Used to read flow links from parsed YAML. - - Args: - link_config: The parsed YAML as a dictionary. - - Returns: - The parsed flow link. - """ - return StaticFlowLink(link_config) - - def as_json(self) -> Text: - """Returns the flow link as a dictionary. - - Returns: - The flow link as a dictionary. - """ - return self.target - - def steps_in_tree(self) -> Generator[FlowStep, None, None]: - """Returns the steps in the tree of the flow link.""" - # static links do not have any child steps - yield from [] - - def child_steps(self) -> List[FlowStep]: - """Returns the child steps of the flow link.""" - return [] - - @property - def target(self) -> Optional[Text]: - """Returns the target of the flow link.""" - return self.target_id diff --git a/rasa/shared/core/flows/flow_step_links.py b/rasa/shared/core/flows/flow_step_links.py new file mode 100644 index 000000000000..0ec45555fba3 --- /dev/null +++ b/rasa/shared/core/flows/flow_step_links.py @@ -0,0 +1,280 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +from dataclasses import dataclass +from typing import List, Union, Dict, Text, Any, Optional, Generator + +from rasa.shared.core.flows.flow_step import FlowStep + +if TYPE_CHECKING: + from rasa.shared.core.flows.flow_step_sequence import StepSequence + + +@dataclass +class FlowLinks: + """Represents the configuration of a list of flow links.""" + + links: List[FlowLink] + + @staticmethod + def from_json(flow_links_config: Union[str, List[Dict[Text, Any]]]) -> FlowLinks: + """Used to read flow links from parsed YAML. + + Args: + flow_links_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow links. + """ + if not flow_links_config: + return FlowLinks(links=[]) + + if isinstance(flow_links_config, str): + return FlowLinks(links=[StaticFlowLink.from_json(flow_links_config)]) + + return FlowLinks( + links=[ + BranchBasedLink.from_json(link_config) + for link_config in flow_links_config + if link_config + ] + ) + + def as_json(self) -> Optional[Union[str, List[Dict[str, Any]]]]: + """Returns the flow links as a dictionary. + + Returns: + The flow links as a dictionary. + """ + if not self.links: + return None + + if len(self.links) == 1 and isinstance(self.links[0], StaticFlowLink): + return self.links[0].as_json() + + return [link.as_json() for link in self.links] + + def no_link_available(self) -> bool: + """Returns whether no link is available.""" + return len(self.links) == 0 + + def steps_in_tree(self) -> Generator[FlowStep, None, None]: + """Returns the steps in the tree of the flow links.""" + for link in self.links: + yield from link.steps_in_tree() + + +class FlowLink: + """Represents a flow link.""" + + @property + def target(self) -> Optional[Text]: + """Returns the target of the flow link. + + Returns: + The target of the flow link. + """ + raise NotImplementedError() + + def as_json(self) -> Any: + """Returns the flow link as a dictionary. + + Returns: + The flow link as a dictionary. + """ + raise NotImplementedError() + + @staticmethod + def from_json(link_config: Any) -> FlowLink: + """Used to read flow links from parsed YAML. + + Args: + link_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow link. + """ + raise NotImplementedError() + + def steps_in_tree(self) -> Generator[FlowStep, None, None]: + """Returns the steps in the tree of the flow link.""" + raise NotImplementedError() + + def child_steps(self) -> List[FlowStep]: + """Returns the child steps of the flow link.""" + raise NotImplementedError() + + +@dataclass +class BranchBasedLink(FlowLink): + target_reference: Union[Text, StepSequence] + """The id of the linked flow.""" + + def steps_in_tree(self) -> Generator[FlowStep, None, None]: + """Returns the steps in the tree of the flow link.""" + from rasa.shared.core.flows.flow_step_sequence import StepSequence + + if isinstance(self.target_reference, StepSequence): + yield from self.target_reference.steps + + def child_steps(self) -> List[FlowStep]: + """Returns the child steps of the flow link.""" + from rasa.shared.core.flows.flow_step_sequence import StepSequence + + if isinstance(self.target_reference, StepSequence): + return self.target_reference.child_steps + else: + return [] + + @property + def target(self) -> Optional[Text]: + """Returns the target of the flow link.""" + from rasa.shared.core.flows.flow_step_sequence import StepSequence + + if isinstance(self.target_reference, StepSequence): + if first := self.target_reference.first(): + return first.id + else: + return None + else: + return self.target_reference + + @staticmethod + def from_json(link_config: Dict[Text, Any]) -> BranchBasedLink: + """Used to read a single flow links from parsed YAML. + + Args: + link_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow link. + """ + if "if" in link_config: + return IfFlowLink.from_json(link_config) + else: + return ElseFlowLink.from_json(link_config) + + +@dataclass +class IfFlowLink(BranchBasedLink): + """Represents the configuration of an if flow link.""" + + condition: Optional[Text] + """The condition of the linked flow.""" + + @staticmethod + def from_json(link_config: Dict[Text, Any]) -> IfFlowLink: + """Used to read flow links from parsed YAML. + + Args: + link_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow link. + """ + from rasa.shared.core.flows.flow_step_sequence import StepSequence + + if isinstance(link_config["then"], str): + return IfFlowLink( + target_reference=link_config["then"], condition=link_config.get("if") + ) + else: + return IfFlowLink( + target_reference=StepSequence.from_json(link_config["then"]), + condition=link_config.get("if"), + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow link as a dictionary. + + Returns: + The flow link as a dictionary. + """ + from rasa.shared.core.flows.flow_step_sequence import StepSequence + + return { + "if": self.condition, + "then": self.target_reference.as_json() + if isinstance(self.target_reference, StepSequence) + else self.target_reference, + } + + +@dataclass +class ElseFlowLink(BranchBasedLink): + """Represents the configuration of an else flow link.""" + + @staticmethod + def from_json(link_config: Dict[Text, Any]) -> ElseFlowLink: + """Used to read flow links from parsed YAML. + + Args: + link_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow link. + """ + from rasa.shared.core.flows.flow_step_sequence import StepSequence + + if isinstance(link_config["else"], str): + return ElseFlowLink(target_reference=link_config["else"]) + else: + return ElseFlowLink( + target_reference=StepSequence.from_json(link_config["else"]) + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow link as a dictionary. + + Returns: + The flow link as a dictionary. + """ + from rasa.shared.core.flows.flow_step_sequence import StepSequence + + return { + "else": self.target_reference.as_json() + if isinstance(self.target_reference, StepSequence) + else self.target_reference + } + + +@dataclass +class StaticFlowLink(FlowLink): + """Represents the configuration of a static flow link.""" + + target_id: Text + """The id of the linked flow.""" + + @staticmethod + def from_json(link_config: Text) -> StaticFlowLink: + """Used to read flow links from parsed YAML. + + Args: + link_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow link. + """ + return StaticFlowLink(link_config) + + def as_json(self) -> Text: + """Returns the flow link as a dictionary. + + Returns: + The flow link as a dictionary. + """ + return self.target + + def steps_in_tree(self) -> Generator[FlowStep, None, None]: + """Returns the steps in the tree of the flow link.""" + # static links do not have any child steps + yield from [] + + def child_steps(self) -> List[FlowStep]: + """Returns the child steps of the flow link.""" + return [] + + @property + def target(self) -> Optional[Text]: + """Returns the target of the flow link.""" + return self.target_id diff --git a/rasa/shared/core/flows/flow_step_sequence.py b/rasa/shared/core/flows/flow_step_sequence.py new file mode 100644 index 000000000000..138d11b7325b --- /dev/null +++ b/rasa/shared/core/flows/flow_step_sequence.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Dict, Text, Any, Optional + +from rasa.shared.core.flows.flow_step import FlowStep, step_from_json +from rasa.shared.core.flows.steps.internal import InternalFlowStep + + +@dataclass +class StepSequence: + child_steps: List[FlowStep] + + @staticmethod + def from_json(steps_config: List[Dict[Text, Any]]) -> StepSequence: + """Used to read steps from parsed YAML. + + Args: + steps_config: The parsed YAML as a dictionary. + + Returns: + The parsed steps. + """ + + flow_steps: List[FlowStep] = [step_from_json(config) for config in steps_config] + + return StepSequence(child_steps=flow_steps) + + def as_json(self) -> List[Dict[Text, Any]]: + """Returns the steps as a dictionary. + + Returns: + The steps as a dictionary. + """ + return [ + step.as_json() + for step in self.child_steps + if not isinstance(step, InternalFlowStep) + ] + + @property + def steps(self) -> List[FlowStep]: + """Returns the steps of the flow.""" + return [ + step + for child_step in self.child_steps + for step in child_step.steps_in_tree() + ] + + def first(self) -> Optional[FlowStep]: + """Returns the first step of the sequence.""" + if len(self.child_steps) == 0: + return None + return self.child_steps[0] diff --git a/rasa/shared/core/flows/steps/__init__.py b/rasa/shared/core/flows/steps/__init__.py new file mode 100644 index 000000000000..cc211ac52c68 --- /dev/null +++ b/rasa/shared/core/flows/steps/__init__.py @@ -0,0 +1,10 @@ +from .action import ActionFlowStep +from .branch import BranchFlowStep +from .collect import CollectInformationFlowStep +from .end import EndFlowStep +from .generate_response import GenerateResponseFlowStep +from .internal import InternalFlowStep +from .link import LinkFlowStep +from .set_slots import SetSlotsFlowStep +from .start import StartFlowStep +from .user_message import UserMessageStep diff --git a/rasa/shared/core/flows/steps/action.py b/rasa/shared/core/flows/steps/action.py new file mode 100644 index 000000000000..f3d6950d50dc --- /dev/null +++ b/rasa/shared/core/flows/steps/action.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Text, Dict, Any, Set + +from rasa.shared.constants import UTTER_PREFIX +from rasa.shared.core.flows.flow_step import FlowStep + + +@dataclass +class ActionFlowStep(FlowStep): + """Represents the configuration of an action flow step.""" + + action: Text + """The action of the flow step.""" + + @classmethod + def from_json(cls, flow_step_config: Dict[Text, Any]) -> ActionFlowStep: + """Used to read flow steps from parsed YAML. + + Args: + flow_step_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow step. + """ + base = super()._from_json(flow_step_config) + return ActionFlowStep( + action=flow_step_config.get("action", ""), + **base.__dict__, + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + dump = super().as_json() + dump["action"] = self.action + return dump + + def default_id_postfix(self) -> str: + return self.action + + @property + def utterances(self) -> Set[str]: + """Return all the utterances used in this step""" + return {self.action} if self.action.startswith(UTTER_PREFIX) else set() diff --git a/rasa/shared/core/flows/steps/branch.py b/rasa/shared/core/flows/steps/branch.py new file mode 100644 index 000000000000..86d5382782c8 --- /dev/null +++ b/rasa/shared/core/flows/steps/branch.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Text, Any + +from rasa.shared.core.flows.flow_step import FlowStep + + +@dataclass +class BranchFlowStep(FlowStep): + """Represents the configuration of a branch flow step.""" + + @classmethod + def from_json(cls, flow_step_config: Dict[Text, Any]) -> BranchFlowStep: + """Used to read flow steps from parsed YAML. + + Args: + flow_step_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow step. + """ + base = super()._from_json(flow_step_config) + return BranchFlowStep(**base.__dict__) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + dump = super().as_json() + return dump + + def default_id_postfix(self) -> str: + """Returns the default id postfix of the flow step.""" + return "branch" diff --git a/rasa/shared/core/flows/steps/collect.py b/rasa/shared/core/flows/steps/collect.py new file mode 100644 index 000000000000..c4b5fe3f5fb5 --- /dev/null +++ b/rasa/shared/core/flows/steps/collect.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Text, Any, List, Set + +from rasa.shared.core.flows.flow_step import FlowStep + + +@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: 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 + """Whether to always ask the question even if the slot is already filled.""" + reset_after_flow_ends: bool = True + """Determines whether to reset the slot value at the end of the flow.""" + + @classmethod + def from_json(cls, flow_step_config: Dict[Text, Any]) -> CollectInformationFlowStep: + """Used to read flow steps from parsed YAML. + + Args: + flow_step_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow step. + """ + base = super()._from_json(flow_step_config) + return CollectInformationFlowStep( + collect=flow_step_config["collect"], + utter=flow_step_config.get( + "utter", f"utter_ask_{flow_step_config['collect']}" + ), + ask_before_filling=flow_step_config.get("ask_before_filling", False), + reset_after_flow_ends=flow_step_config.get("reset_after_flow_ends", True), + rejections=[ + SlotRejection.from_dict(rejection) + for rejection in flow_step_config.get("rejections", []) + ], + **base.__dict__, + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + dump = super().as_json() + dump["collect"] = self.collect + dump["utter"] = self.utter + dump["ask_before_filling"] = self.ask_before_filling + dump["reset_after_flow_ends"] = self.reset_after_flow_ends + dump["rejections"] = [rejection.as_dict() for rejection in self.rejections] + + return dump + + def default_id_postfix(self) -> str: + """Returns the default id postfix of the flow step.""" + return f"collect_{self.collect}" + + @property + def utterances(self) -> Set[str]: + """Return all the utterances used in this step""" + return {self.utter} | {r.utter for r in self.rejections} diff --git a/rasa/shared/core/flows/steps/constants.py b/rasa/shared/core/flows/steps/constants.py new file mode 100644 index 000000000000..f1069042a185 --- /dev/null +++ b/rasa/shared/core/flows/steps/constants.py @@ -0,0 +1,4 @@ +CONTINUE_STEP_PREFIX = "NEXT:" +START_STEP = "START" +END_STEP = "END" +DEFAULT_STEPS = {END_STEP, START_STEP} diff --git a/rasa/shared/core/flows/steps/continuation.py b/rasa/shared/core/flows/steps/continuation.py new file mode 100644 index 000000000000..ed1b5466c441 --- /dev/null +++ b/rasa/shared/core/flows/steps/continuation.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from rasa.shared.core.flows.flow_step_links import FlowLinks, StaticFlowLink +from rasa.shared.core.flows.steps.constants import CONTINUE_STEP_PREFIX +from rasa.shared.core.flows.steps.internal import InternalFlowStep + + +@dataclass +class ContinueFlowStep(InternalFlowStep): + """Represents the configuration of a continue-step flow step.""" + + def __init__(self, next: str) -> None: + """Initializes a continue-step flow step.""" + super().__init__( + idx=0, + custom_id=CONTINUE_STEP_PREFIX + next, + description=None, + metadata={}, + # The continue step links to the step that should be continued. + # The flow policy in a sense only "runs" the logic of a step + # when it transitions to that step, once it is there it will use + # the next link to transition to the next step. This means that + # if we want to "re-run" a step, we need to link to it again. + # This is why the continue step links to the step that should be + # continued. + next=FlowLinks(links=[StaticFlowLink(next)]), + ) + + @staticmethod + def continue_step_for_id(step_id: str) -> str: + return CONTINUE_STEP_PREFIX + step_id diff --git a/rasa/shared/core/flows/steps/end.py b/rasa/shared/core/flows/steps/end.py new file mode 100644 index 000000000000..156ea2c372b6 --- /dev/null +++ b/rasa/shared/core/flows/steps/end.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from rasa.shared.core.flows.flow_step_links import FlowLinks +from rasa.shared.core.flows.steps.constants import END_STEP +from rasa.shared.core.flows.steps.internal import InternalFlowStep + + +@dataclass +class EndFlowStep(InternalFlowStep): + """Represents the configuration of an end to a flow.""" + + def __init__(self) -> None: + """Initializes an end flow step.""" + super().__init__( + idx=0, + custom_id=END_STEP, + description=None, + metadata={}, + next=FlowLinks(links=[]), + ) diff --git a/rasa/shared/core/flows/steps/generate_response.py b/rasa/shared/core/flows/steps/generate_response.py new file mode 100644 index 000000000000..45bf13e9def9 --- /dev/null +++ b/rasa/shared/core/flows/steps/generate_response.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Text, Optional, Dict, Any + +from rasa.shared.core.flows.flow_step import FlowStep, structlogger +from rasa.shared.core.trackers import DialogueStateTracker + +from rasa.shared.utils.llm import ( + DEFAULT_OPENAI_TEMPERATURE, + DEFAULT_OPENAI_GENERATE_MODEL_NAME, +) + +DEFAULT_LLM_CONFIG = { + "_type": "openai", + "request_timeout": 5, + "temperature": DEFAULT_OPENAI_TEMPERATURE, + "model_name": DEFAULT_OPENAI_GENERATE_MODEL_NAME, +} + + +@dataclass +class GenerateResponseFlowStep(FlowStep): + """Represents the configuration of a step prompting an LLM.""" + + generation_prompt: Text + """The prompt template of the flow step.""" + llm_config: Optional[Dict[Text, Any]] = None + """The LLM configuration of the flow step.""" + + @classmethod + def from_json(cls, flow_step_config: Dict[Text, Any]) -> GenerateResponseFlowStep: + """Used to read flow steps from parsed YAML. + + Args: + flow_step_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow step. + """ + base = super()._from_json(flow_step_config) + return GenerateResponseFlowStep( + generation_prompt=flow_step_config.get("generation_prompt", ""), + llm_config=flow_step_config.get("llm", None), + **base.__dict__, + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + dump = super().as_json() + dump["generation_prompt"] = self.generation_prompt + if self.llm_config: + dump["llm"] = self.llm_config + + return dump + + def generate(self, tracker: DialogueStateTracker) -> Optional[Text]: + """Generates a response for the given tracker. + + Args: + tracker: The tracker to generate a response for. + + Returns: + The generated response. + """ + from rasa.shared.utils.llm import llm_factory, tracker_as_readable_transcript + from jinja2 import Template + + context = { + "history": tracker_as_readable_transcript(tracker, max_turns=5), + "latest_user_message": tracker.latest_message.text + if tracker.latest_message + else "", + } + context.update(tracker.current_slot_values()) + + llm = llm_factory(self.llm_config, DEFAULT_LLM_CONFIG) + prompt = Template(self.generation_prompt).render(context) + + try: + return llm(prompt) + except Exception as e: + # unfortunately, langchain does not wrap LLM exceptions which means + # we have to catch all exceptions here + structlogger.error( + "flow.generate_step.llm.error", error=e, step=self.id, prompt=prompt + ) + return None + + def default_id_postfix(self) -> str: + return "generate" diff --git a/rasa/shared/core/flows/steps/internal.py b/rasa/shared/core/flows/steps/internal.py new file mode 100644 index 000000000000..ab88da32cb0c --- /dev/null +++ b/rasa/shared/core/flows/steps/internal.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from typing import Dict, Text, Any + +from rasa.shared.core.flows.flow_step import FlowStep + + +class InternalFlowStep(FlowStep): + """Represents the configuration of a built-in flow step. + + Built in flow steps are required to manage the lifecycle of a + flow and are not intended to be used by users. + """ + + @classmethod + def from_json(cls, flow_step_config: Dict[Text, Any]) -> InternalFlowStep: + """Used to read flow steps from parsed JSON. + + Args: + flow_step_config: The parsed JSON as a dictionary. + + Returns: + The parsed flow step. + """ + raise ValueError("A start step cannot be parsed.") + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + raise ValueError("A start step cannot be dumped.") diff --git a/rasa/shared/core/flows/steps/link.py b/rasa/shared/core/flows/steps/link.py new file mode 100644 index 000000000000..5eb4ca5c2060 --- /dev/null +++ b/rasa/shared/core/flows/steps/link.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Text, Dict, Any + +from rasa.shared.core.flows.flow_step import FlowStep + + +@dataclass +class LinkFlowStep(FlowStep): + """Represents the configuration of a link flow step.""" + + link: Text + """The link of the flow step.""" + + @classmethod + def from_json(cls, flow_step_config: Dict[Text, Any]) -> LinkFlowStep: + """Used to read flow steps from parsed YAML. + + Args: + flow_step_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow step. + """ + base = super()._from_json(flow_step_config) + return LinkFlowStep( + link=flow_step_config.get("link", ""), + **base.__dict__, + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + dump = super().as_json() + dump["link"] = self.link + return dump + + def default_id_postfix(self) -> str: + """Returns the default id postfix of the flow step.""" + return f"link_{self.link}" diff --git a/rasa/shared/core/flows/steps/set_slots.py b/rasa/shared/core/flows/steps/set_slots.py new file mode 100644 index 000000000000..b424cf41075b --- /dev/null +++ b/rasa/shared/core/flows/steps/set_slots.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Dict, Any, Text + +from rasa.shared.core.flows.flow_step import FlowStep + + +@dataclass +class SetSlotsFlowStep(FlowStep): + """Represents the configuration of a set_slots flow step.""" + + slots: List[Dict[str, Any]] + """Slots to set of the flow step.""" + + @classmethod + def from_json(cls, flow_step_config: Dict[Text, Any]) -> SetSlotsFlowStep: + """Used to read flow steps from parsed YAML. + + Args: + flow_step_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow step. + """ + base = super()._from_json(flow_step_config) + slots = [ + {"key": k, "value": v} + for slot in flow_step_config.get("set_slots", []) + for k, v in slot.items() + ] + return SetSlotsFlowStep( + slots=slots, + **base.__dict__, + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + dump = super().as_json() + dump["set_slots"] = [{slot["key"]: slot["value"]} for slot in self.slots] + return dump + + def default_id_postfix(self) -> str: + """Returns the default id postfix of the flow step.""" + return "set_slots" diff --git a/rasa/shared/core/flows/steps/start.py b/rasa/shared/core/flows/steps/start.py new file mode 100644 index 000000000000..8aa8457c82b1 --- /dev/null +++ b/rasa/shared/core/flows/steps/start.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Text, List + +from rasa.shared.core.flows.flow_step_links import FlowLinks, FlowLink, StaticFlowLink +from rasa.shared.core.flows.steps.constants import START_STEP +from rasa.shared.core.flows.steps.internal import InternalFlowStep + + +@dataclass +class StartFlowStep(InternalFlowStep): + """Represents the configuration of a start flow step.""" + + def __init__(self, start_step_id: Optional[Text]) -> None: + """Initializes a start flow step. + + Args: + start_step: The step to start the flow from. + """ + if start_step_id is not None: + links: List[FlowLink] = [StaticFlowLink(start_step_id)] + else: + links = [] + + super().__init__( + idx=0, + custom_id=START_STEP, + description=None, + metadata={}, + next=FlowLinks(links=links), + ) diff --git a/rasa/shared/core/flows/steps/user_message.py b/rasa/shared/core/flows/steps/user_message.py new file mode 100644 index 000000000000..559b0b9d7048 --- /dev/null +++ b/rasa/shared/core/flows/steps/user_message.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Text, List, runtime_checkable, Protocol, Dict, Any + +from rasa.shared.core.flows.flow_step import FlowStep +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.nlu.constants import INTENT_NAME_KEY, ENTITY_ATTRIBUTE_TYPE + + +@dataclass +class TriggerCondition: + """Represents the configuration of a trigger condition.""" + + intent: Text + """The intent to trigger the flow.""" + entities: List[Text] + """The entities to trigger the flow.""" + + def is_triggered(self, intent: Text, entities: List[Text]) -> bool: + """Check if condition is triggered by the given intent and entities. + + Args: + intent: The intent to check. + entities: The entities to check. + + Returns: + Whether the trigger condition is triggered by the given intent and entities. + """ + if self.intent != intent: + return False + if len(self.entities) == 0: + return True + return all(entity in entities for entity in self.entities) + + +@runtime_checkable +class StepThatCanStartAFlow(Protocol): + """Represents a step that can start a flow.""" + + def is_triggered(self, tracker: DialogueStateTracker) -> bool: + """Check if a flow should be started for the tracker + + Args: + tracker: The tracker to check. + + Returns: + Whether a flow should be started for the tracker. + """ + ... + + +@dataclass +class UserMessageStep(FlowStep, StepThatCanStartAFlow): + """Represents the configuration of an intent flow step.""" + + trigger_conditions: List[TriggerCondition] + """The trigger conditions of the flow step.""" + + @classmethod + def from_json(cls, flow_step_config: Dict[Text, Any]) -> UserMessageStep: + """Used to read flow steps from parsed YAML. + + Args: + flow_step_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow step. + """ + base = super()._from_json(flow_step_config) + + trigger_conditions = [] + if "intent" in flow_step_config: + trigger_conditions.append( + TriggerCondition( + intent=flow_step_config["intent"], + entities=flow_step_config.get("entities", []), + ) + ) + elif "or" in flow_step_config: + for trigger_condition in flow_step_config["or"]: + trigger_conditions.append( + TriggerCondition( + intent=trigger_condition.get("intent", ""), + entities=trigger_condition.get("entities", []), + ) + ) + + return UserMessageStep( + trigger_conditions=trigger_conditions, + **base.__dict__, + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + dump = super().as_json() + + if len(self.trigger_conditions) == 1: + dump["intent"] = self.trigger_conditions[0].intent + if self.trigger_conditions[0].entities: + dump["entities"] = self.trigger_conditions[0].entities + elif len(self.trigger_conditions) > 1: + dump["or"] = [ + { + "intent": trigger_condition.intent, + "entities": trigger_condition.entities, + } + for trigger_condition in self.trigger_conditions + ] + + return dump + + def is_triggered(self, tracker: DialogueStateTracker) -> bool: + """Returns whether the flow step is triggered by the given intent and entities. + + Args: + intent: The intent to check. + entities: The entities to check. + + Returns: + Whether the flow step is triggered by the given intent and entities. + """ + if not tracker.latest_message: + return False + + intent: Text = tracker.latest_message.intent.get(INTENT_NAME_KEY, "") + entities: List[Text] = [ + e.get(ENTITY_ATTRIBUTE_TYPE, "") for e in tracker.latest_message.entities + ] + return any( + trigger_condition.is_triggered(intent, entities) + for trigger_condition in self.trigger_conditions + ) + + def default_id_postfix(self) -> str: + """Returns the default id postfix of the flow step.""" + return "intent" diff --git a/rasa/shared/core/flows/validation.py b/rasa/shared/core/flows/validation.py index 626ea6f9159f..6acf8240c5b8 100644 --- a/rasa/shared/core/flows/validation.py +++ b/rasa/shared/core/flows/validation.py @@ -3,14 +3,15 @@ from typing import Optional, Set, Text from rasa.shared.core.flows.flow_step import ( + FlowStep, +) +from rasa.shared.core.flows.flow_step_links import ( BranchBasedLink, - DEFAULT_STEPS, - CONTINUE_STEP_PREFIX, IfFlowLink, ElseFlowLink, - LinkFlowStep, - FlowStep, ) +from rasa.shared.core.flows.steps.constants import CONTINUE_STEP_PREFIX, DEFAULT_STEPS +from rasa.shared.core.flows.steps.link import LinkFlowStep from rasa.shared.core.flows.flow import Flow from rasa.shared.exceptions import RasaException diff --git a/rasa/validator.py b/rasa/validator.py index 73d120b8e5a2..750437463de6 100644 --- a/rasa/validator.py +++ b/rasa/validator.py @@ -7,13 +7,11 @@ from pypred import Predicate import rasa.core.training.story_conflict -from rasa.shared.core.flows.flow_step import ( - ActionFlowStep, - BranchFlowStep, - CollectInformationFlowStep, - IfFlowLink, - SetSlotsFlowStep, -) +from rasa.shared.core.flows.flow_step_links import IfFlowLink +from rasa.shared.core.flows.steps.set_slots import SetSlotsFlowStep +from rasa.shared.core.flows.steps.collect import CollectInformationFlowStep +from rasa.shared.core.flows.steps.branch import BranchFlowStep +from rasa.shared.core.flows.steps.action import ActionFlowStep from rasa.shared.core.flows.flows_list import FlowsList import rasa.shared.nlu.constants from rasa.shared.constants import ( diff --git a/tests/dialogue_understanding/commands/test_handle_code_change_command.py b/tests/dialogue_understanding/commands/test_handle_code_change_command.py index c41f39ba8008..9c65db2c5110 100644 --- a/tests/dialogue_understanding/commands/test_handle_code_change_command.py +++ b/tests/dialogue_understanding/commands/test_handle_code_change_command.py @@ -16,11 +16,8 @@ ) from rasa.shared.core.domain import Domain from rasa.shared.core.events import SlotSet -from rasa.shared.core.flows.flow_step import ( - START_STEP, - ContinueFlowStep, - END_STEP, -) +from rasa.shared.core.flows.steps.constants import START_STEP, END_STEP +from rasa.shared.core.flows.steps.continuation import ContinueFlowStep from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.trackers import DialogueStateTracker from tests.dialogue_understanding.commands.test_command_processor import ( diff --git a/tests/dialogue_understanding/generator/test_llm_command_generator.py b/tests/dialogue_understanding/generator/test_llm_command_generator.py index 830a789b4cca..2e5bc251e494 100644 --- a/tests/dialogue_understanding/generator/test_llm_command_generator.py +++ b/tests/dialogue_understanding/generator/test_llm_command_generator.py @@ -26,9 +26,9 @@ from rasa.engine.storage.resource import Resource from rasa.engine.storage.storage import ModelStorage from rasa.shared.core.events import BotUttered, SlotSet, UserUttered -from rasa.shared.core.flows.flow_step import ( - CollectInformationFlowStep, +from rasa.shared.core.flows.steps.collect import ( SlotRejection, + CollectInformationFlowStep, ) from rasa.shared.core.flows.flows_list import FlowsList from rasa.shared.core.slots import ( diff --git a/tests/dialogue_understanding/stack/frames/test_flow_frame.py b/tests/dialogue_understanding/stack/frames/test_flow_frame.py index 7df9bbaa69af..4df16555784c 100644 --- a/tests/dialogue_understanding/stack/frames/test_flow_frame.py +++ b/tests/dialogue_understanding/stack/frames/test_flow_frame.py @@ -6,11 +6,9 @@ UserFlowStackFrame, FlowStackFrameType, ) -from rasa.shared.core.flows.flow_step import ( - ActionFlowStep, - FlowLinks, - StepSequence, -) +from rasa.shared.core.flows.flow_step_links import FlowLinks +from rasa.shared.core.flows.steps.action import ActionFlowStep +from rasa.shared.core.flows.flow_step_sequence import StepSequence from rasa.shared.core.flows.flow import Flow from rasa.shared.core.flows.flows_list import FlowsList From 3d4459ec1663ee640cfc231e23ddf19549fa39f0 Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Thu, 26 Oct 2023 10:52:38 +0200 Subject: [PATCH 20/31] Improved typing, naming and docs for flow step links --- rasa/core/policies/flow_policy.py | 12 +- rasa/shared/core/flows/flow.py | 6 +- rasa/shared/core/flows/flow_step.py | 8 +- rasa/shared/core/flows/flow_step_links.py | 181 +++++++++--------- rasa/shared/core/flows/steps/continuation.py | 4 +- rasa/shared/core/flows/steps/end.py | 4 +- rasa/shared/core/flows/steps/start.py | 10 +- rasa/shared/core/flows/validation.py | 12 +- rasa/validator.py | 4 +- .../stack/frames/test_flow_frame.py | 4 +- 10 files changed, 125 insertions(+), 120 deletions(-) diff --git a/rasa/core/policies/flow_policy.py b/rasa/core/policies/flow_policy.py index aa7fa5429e6a..7ffe5f39e2d8 100644 --- a/rasa/core/policies/flow_policy.py +++ b/rasa/core/policies/flow_policy.py @@ -48,9 +48,9 @@ FlowStep, ) from rasa.shared.core.flows.flow_step_links import ( - IfFlowLink, - ElseFlowLink, - StaticFlowLink, + IfFlowStepLink, + ElseFlowStepLink, + StaticFlowStepLink, ) from rasa.shared.core.flows.steps.constants import END_STEP from rasa.shared.core.flows.steps.continuation import ContinueFlowStep @@ -370,18 +370,18 @@ def _select_next_step_id( ) -> Optional[Text]: """Selects the next step id based on the current step.""" next = current.next - if len(next.links) == 1 and isinstance(next.links[0], StaticFlowLink): + if len(next.links) == 1 and isinstance(next.links[0], StaticFlowStepLink): return next.links[0].target # evaluate if conditions for link in next.links: - if isinstance(link, IfFlowLink) and link.condition: + if isinstance(link, IfFlowStepLink) and link.condition: if self.is_condition_satisfied(link.condition, tracker): return link.target # evaluate else condition for link in next.links: - if isinstance(link, ElseFlowLink): + if isinstance(link, ElseFlowStepLink): return link.target if next.links: diff --git a/rasa/shared/core/flows/flow.py b/rasa/shared/core/flows/flow.py index 1bc0d5a58c73..a6dfe67bbf89 100644 --- a/rasa/shared/core/flows/flow.py +++ b/rasa/shared/core/flows/flow.py @@ -9,7 +9,7 @@ from rasa.shared.core.flows.flow_step import ( FlowStep, ) -from rasa.shared.core.flows.flow_step_links import StaticFlowLink +from rasa.shared.core.flows.flow_step_links import StaticFlowStepLink from rasa.shared.core.flows.steps.continuation import ContinueFlowStep from rasa.shared.core.flows.steps.constants import ( CONTINUE_STEP_PREFIX, @@ -89,9 +89,9 @@ def resolve_default_next(steps: List[FlowStep], is_root_sequence: bool) -> None: # if this is the root sequence, we need to add an end step # to the end of the sequence. other sequences, e.g. # in branches need to explicitly add a next step. - step.next.links.append(StaticFlowLink(END_STEP)) + step.next.links.append(StaticFlowStepLink(END_STEP)) else: - step.next.links.append(StaticFlowLink(steps[i + 1].id)) + step.next.links.append(StaticFlowStepLink(steps[i + 1].id)) for link in step.next.links: if sub_steps := link.child_steps(): resolve_default_next(sub_steps, is_root_sequence=False) diff --git a/rasa/shared/core/flows/flow_step.py b/rasa/shared/core/flows/flow_step.py index 8ad5241b92b4..482224ca715d 100644 --- a/rasa/shared/core/flows/flow_step.py +++ b/rasa/shared/core/flows/flow_step.py @@ -13,7 +13,7 @@ import structlog if TYPE_CHECKING: - from rasa.shared.core.flows.flow_step_links import FlowLinks + from rasa.shared.core.flows.flow_step_links import FlowStepLinks structlogger = structlog.get_logger() @@ -65,7 +65,7 @@ class FlowStep: """The description of the flow step.""" metadata: Dict[Text, Any] """Additional, unstructured information about this flow step.""" - next: FlowLinks + next: FlowStepLinks """The next steps of the flow step.""" @classmethod @@ -78,7 +78,7 @@ def _from_json(cls, flow_step_config: Dict[Text, Any]) -> FlowStep: Returns: The parsed flow step. """ - from rasa.shared.core.flows.flow_step_links import FlowLinks + from rasa.shared.core.flows.flow_step_links import FlowStepLinks return FlowStep( # the idx is set later once the flow is created that contains @@ -87,7 +87,7 @@ def _from_json(cls, flow_step_config: Dict[Text, Any]) -> FlowStep: custom_id=flow_step_config.get("id"), description=flow_step_config.get("description"), metadata=flow_step_config.get("metadata", {}), - next=FlowLinks.from_json(flow_step_config.get("next", [])), + next=FlowStepLinks.from_json(flow_step_config.get("next", [])), ) def as_json(self) -> Dict[Text, Any]: diff --git a/rasa/shared/core/flows/flow_step_links.py b/rasa/shared/core/flows/flow_step_links.py index 0ec45555fba3..eeff69c28348 100644 --- a/rasa/shared/core/flows/flow_step_links.py +++ b/rasa/shared/core/flows/flow_step_links.py @@ -11,45 +11,45 @@ @dataclass -class FlowLinks: - """Represents the configuration of a list of flow links.""" +class FlowStepLinks: + """A list of flow step links.""" - links: List[FlowLink] + links: List[FlowStepLink] @staticmethod - def from_json(flow_links_config: Union[str, List[Dict[Text, Any]]]) -> FlowLinks: - """Used to read flow links from parsed YAML. + def from_json(data: Union[str, List[Dict[Text, Any]]]) -> FlowStepLinks: + """Create a FlowStepLinks object from a serialized data format. Args: - flow_links_config: The parsed YAML as a dictionary. + data: data for a FlowStepLinks object in a serialized format. Returns: - The parsed flow links. + A FlowStepLinks object. """ - if not flow_links_config: - return FlowLinks(links=[]) + if not data: + return FlowStepLinks(links=[]) - if isinstance(flow_links_config, str): - return FlowLinks(links=[StaticFlowLink.from_json(flow_links_config)]) + if isinstance(data, str): + return FlowStepLinks(links=[StaticFlowStepLink.from_json(data)]) - return FlowLinks( + return FlowStepLinks( links=[ - BranchBasedLink.from_json(link_config) - for link_config in flow_links_config + BranchingFlowStepLink.from_json(link_config) + for link_config in data if link_config ] ) def as_json(self) -> Optional[Union[str, List[Dict[str, Any]]]]: - """Returns the flow links as a dictionary. + """Serialize the FlowStepLinks object. Returns: - The flow links as a dictionary. + The FlowStepLinks object as serialized data. """ if not self.links: return None - if len(self.links) == 1 and isinstance(self.links[0], StaticFlowLink): + if len(self.links) == 1 and isinstance(self.links[0], StaticFlowStepLink): return self.links[0].as_json() return [link.as_json() for link in self.links] @@ -59,66 +59,66 @@ def no_link_available(self) -> bool: return len(self.links) == 0 def steps_in_tree(self) -> Generator[FlowStep, None, None]: - """Returns the steps in the tree of the flow links.""" + """Returns the steps in the tree of the flow step links.""" for link in self.links: yield from link.steps_in_tree() -class FlowLink: - """Represents a flow link.""" +class FlowStepLink: + """A flow step link that links two steps in a single flow.""" @property - def target(self) -> Optional[Text]: - """Returns the target of the flow link. + def target(self) -> Text: + """Returns the target flow step id. Returns: - The target of the flow link. + The target flow step id. """ raise NotImplementedError() def as_json(self) -> Any: - """Returns the flow link as a dictionary. + """Serialize the FlowStepLink object. Returns: - The flow link as a dictionary. + The FlowStepLink as serialized data. """ raise NotImplementedError() @staticmethod - def from_json(link_config: Any) -> FlowLink: - """Used to read flow links from parsed YAML. + def from_json(data: Any) -> FlowStepLink: + """Create a FlowStepLink object from a serialized data format. Args: - link_config: The parsed YAML as a dictionary. + data: data for a FlowStepLink object in a serialized format. Returns: - The parsed flow link. + The FlowStepLink object. """ raise NotImplementedError() def steps_in_tree(self) -> Generator[FlowStep, None, None]: - """Returns the steps in the tree of the flow link.""" + """Recursively generates the steps in the tree.""" raise NotImplementedError() def child_steps(self) -> List[FlowStep]: - """Returns the child steps of the flow link.""" + """Returns the steps of the linked FlowStepSequence if any.""" raise NotImplementedError() @dataclass -class BranchBasedLink(FlowLink): +class BranchingFlowStepLink(FlowStepLink): target_reference: Union[Text, StepSequence] - """The id of the linked flow.""" + """The id of the linked step or a sequence of steps.""" def steps_in_tree(self) -> Generator[FlowStep, None, None]: - """Returns the steps in the tree of the flow link.""" + """Recursively generates the steps in the tree.""" from rasa.shared.core.flows.flow_step_sequence import StepSequence if isinstance(self.target_reference, StepSequence): yield from self.target_reference.steps def child_steps(self) -> List[FlowStep]: - """Returns the child steps of the flow link.""" + """Returns the steps of the linked flow step sequence if any.""" from rasa.shared.core.flows.flow_step_sequence import StepSequence if isinstance(self.target_reference, StepSequence): @@ -127,68 +127,69 @@ def child_steps(self) -> List[FlowStep]: return [] @property - def target(self) -> Optional[Text]: - """Returns the target of the flow link.""" + def target(self) -> Text: + """Return the target flow step id.""" from rasa.shared.core.flows.flow_step_sequence import StepSequence if isinstance(self.target_reference, StepSequence): if first := self.target_reference.first(): return first.id else: - return None + raise RuntimeError( + "Step sequence is empty despite previous validation of " + "this not happening" + ) else: return self.target_reference @staticmethod - def from_json(link_config: Dict[Text, Any]) -> BranchBasedLink: - """Used to read a single flow links from parsed YAML. + def from_json(data: Dict[Text, Any]) -> BranchingFlowStepLink: + """Create a BranchingFlowStepLink object from a serialized data format. Args: - link_config: The parsed YAML as a dictionary. + data: data for a BranchingFlowStepLink object in a serialized format. Returns: - The parsed flow link. + a BranchingFlowStepLink object. """ - if "if" in link_config: - return IfFlowLink.from_json(link_config) + if "if" in data: + return IfFlowStepLink.from_json(data) else: - return ElseFlowLink.from_json(link_config) + return ElseFlowStepLink.from_json(data) @dataclass -class IfFlowLink(BranchBasedLink): - """Represents the configuration of an if flow link.""" +class IfFlowStepLink(BranchingFlowStepLink): + """A flow step link that links to another step or step sequence conditionally.""" - condition: Optional[Text] - """The condition of the linked flow.""" + condition: Text + """The condition that needs to be satisfied to follow this flow step link.""" @staticmethod - def from_json(link_config: Dict[Text, Any]) -> IfFlowLink: - """Used to read flow links from parsed YAML. + def from_json(data: Dict[Text, Any]) -> IfFlowStepLink: + """Create an IfFlowStepLink object from a serialized data format. Args: - link_config: The parsed YAML as a dictionary. + data: data for a IfFlowStepLink in a serialized format. Returns: - The parsed flow link. + An IfFlowStepLink object. """ from rasa.shared.core.flows.flow_step_sequence import StepSequence - if isinstance(link_config["then"], str): - return IfFlowLink( - target_reference=link_config["then"], condition=link_config.get("if") - ) + if isinstance(data["then"], str): + return IfFlowStepLink(target_reference=data["then"], condition=data["if"]) else: - return IfFlowLink( - target_reference=StepSequence.from_json(link_config["then"]), - condition=link_config.get("if"), + return IfFlowStepLink( + target_reference=StepSequence.from_json(data["then"]), + condition=data["if"], ) def as_json(self) -> Dict[Text, Any]: - """Returns the flow link as a dictionary. + """Serialize the IfFlowStepLink object. Returns: - The flow link as a dictionary. + the IfFlowStepLink object as serialized data. """ from rasa.shared.core.flows.flow_step_sequence import StepSequence @@ -201,33 +202,33 @@ def as_json(self) -> Dict[Text, Any]: @dataclass -class ElseFlowLink(BranchBasedLink): - """Represents the configuration of an else flow link.""" +class ElseFlowStepLink(BranchingFlowStepLink): + """A flow step link that is taken when conditional flow step links weren't taken.""" @staticmethod - def from_json(link_config: Dict[Text, Any]) -> ElseFlowLink: - """Used to read flow links from parsed YAML. + def from_json(data: Dict[Text, Any]) -> ElseFlowStepLink: + """Create an ElseFlowStepLink object from serialized data. Args: - link_config: The parsed YAML as a dictionary. + data: data for an ElseFlowStepLink in a serialized format Returns: - The parsed flow link. + An ElseFlowStepLink """ from rasa.shared.core.flows.flow_step_sequence import StepSequence - if isinstance(link_config["else"], str): - return ElseFlowLink(target_reference=link_config["else"]) + if isinstance(data["else"], str): + return ElseFlowStepLink(target_reference=data["else"]) else: - return ElseFlowLink( - target_reference=StepSequence.from_json(link_config["else"]) + return ElseFlowStepLink( + target_reference=StepSequence.from_json(data["else"]) ) def as_json(self) -> Dict[Text, Any]: - """Returns the flow link as a dictionary. + """Serialize the ElseFlowStepLink object Returns: - The flow link as a dictionary. + The ElseFlowStepLink as serialized data. """ from rasa.shared.core.flows.flow_step_sequence import StepSequence @@ -239,42 +240,42 @@ def as_json(self) -> Dict[Text, Any]: @dataclass -class StaticFlowLink(FlowLink): - """Represents the configuration of a static flow link.""" +class StaticFlowStepLink(FlowStepLink): + """A static flow step link, linking to a step in the same flow unconditionally.""" - target_id: Text - """The id of the linked flow.""" + target_step_id: Text + """The id of the linked step.""" @staticmethod - def from_json(link_config: Text) -> StaticFlowLink: - """Used to read flow links from parsed YAML. + def from_json(data: Text) -> StaticFlowStepLink: + """Create a StaticFlowStepLink from serialized data Args: - link_config: The parsed YAML as a dictionary. + data: data for a StaticFlowStepLink in a serialized format Returns: - The parsed flow link. + A StaticFlowStepLink object """ - return StaticFlowLink(link_config) + return StaticFlowStepLink(data) def as_json(self) -> Text: - """Returns the flow link as a dictionary. + """Serialize the StaticFlowStepLink object Returns: - The flow link as a dictionary. + The StaticFlowStepLink object as serialized data. """ return self.target def steps_in_tree(self) -> Generator[FlowStep, None, None]: - """Returns the steps in the tree of the flow link.""" + """Recursively generates the steps in the tree.""" # static links do not have any child steps yield from [] def child_steps(self) -> List[FlowStep]: - """Returns the child steps of the flow link.""" + """Returns the steps of the linked FlowStepSequence if any.""" return [] @property - def target(self) -> Optional[Text]: - """Returns the target of the flow link.""" - return self.target_id + def target(self) -> Text: + """Returns the target step id.""" + return self.target_step_id diff --git a/rasa/shared/core/flows/steps/continuation.py b/rasa/shared/core/flows/steps/continuation.py index ed1b5466c441..ae561e70803e 100644 --- a/rasa/shared/core/flows/steps/continuation.py +++ b/rasa/shared/core/flows/steps/continuation.py @@ -2,7 +2,7 @@ from dataclasses import dataclass -from rasa.shared.core.flows.flow_step_links import FlowLinks, StaticFlowLink +from rasa.shared.core.flows.flow_step_links import FlowStepLinks, StaticFlowStepLink from rasa.shared.core.flows.steps.constants import CONTINUE_STEP_PREFIX from rasa.shared.core.flows.steps.internal import InternalFlowStep @@ -25,7 +25,7 @@ def __init__(self, next: str) -> None: # if we want to "re-run" a step, we need to link to it again. # This is why the continue step links to the step that should be # continued. - next=FlowLinks(links=[StaticFlowLink(next)]), + next=FlowStepLinks(links=[StaticFlowStepLink(next)]), ) @staticmethod diff --git a/rasa/shared/core/flows/steps/end.py b/rasa/shared/core/flows/steps/end.py index 156ea2c372b6..331f78125851 100644 --- a/rasa/shared/core/flows/steps/end.py +++ b/rasa/shared/core/flows/steps/end.py @@ -2,7 +2,7 @@ from dataclasses import dataclass -from rasa.shared.core.flows.flow_step_links import FlowLinks +from rasa.shared.core.flows.flow_step_links import FlowStepLinks from rasa.shared.core.flows.steps.constants import END_STEP from rasa.shared.core.flows.steps.internal import InternalFlowStep @@ -18,5 +18,5 @@ def __init__(self) -> None: custom_id=END_STEP, description=None, metadata={}, - next=FlowLinks(links=[]), + next=FlowStepLinks(links=[]), ) diff --git a/rasa/shared/core/flows/steps/start.py b/rasa/shared/core/flows/steps/start.py index 8aa8457c82b1..a1336da8cfff 100644 --- a/rasa/shared/core/flows/steps/start.py +++ b/rasa/shared/core/flows/steps/start.py @@ -3,7 +3,11 @@ from dataclasses import dataclass from typing import Optional, Text, List -from rasa.shared.core.flows.flow_step_links import FlowLinks, FlowLink, StaticFlowLink +from rasa.shared.core.flows.flow_step_links import ( + FlowStepLinks, + FlowStepLink, + StaticFlowStepLink, +) from rasa.shared.core.flows.steps.constants import START_STEP from rasa.shared.core.flows.steps.internal import InternalFlowStep @@ -19,7 +23,7 @@ def __init__(self, start_step_id: Optional[Text]) -> None: start_step: The step to start the flow from. """ if start_step_id is not None: - links: List[FlowLink] = [StaticFlowLink(start_step_id)] + links: List[FlowStepLink] = [StaticFlowStepLink(start_step_id)] else: links = [] @@ -28,5 +32,5 @@ def __init__(self, start_step_id: Optional[Text]) -> None: custom_id=START_STEP, description=None, metadata={}, - next=FlowLinks(links=links), + next=FlowStepLinks(links=links), ) diff --git a/rasa/shared/core/flows/validation.py b/rasa/shared/core/flows/validation.py index 6acf8240c5b8..2791b00ab763 100644 --- a/rasa/shared/core/flows/validation.py +++ b/rasa/shared/core/flows/validation.py @@ -6,9 +6,9 @@ FlowStep, ) from rasa.shared.core.flows.flow_step_links import ( - BranchBasedLink, - IfFlowLink, - ElseFlowLink, + BranchingFlowStepLink, + IfFlowStepLink, + ElseFlowStepLink, ) from rasa.shared.core.flows.steps.constants import CONTINUE_STEP_PREFIX, DEFAULT_STEPS from rasa.shared.core.flows.steps.link import LinkFlowStep @@ -198,7 +198,7 @@ def validate_no_empty_step_sequences(flow: Flow) -> None: """Validate that the flow does not have any empty step sequences.""" for step in flow.steps: for link in step.next.links: - if isinstance(link, BranchBasedLink) and link.target is None: + if isinstance(link, BranchingFlowStepLink) and link.target is None: raise EmptyStepSequenceException(flow.id, step.id) @@ -214,8 +214,8 @@ def validate_all_branches_have_an_else(flow: Flow) -> None: for step in flow.steps: links = step.next.links - has_an_if = any(isinstance(link, IfFlowLink) for link in links) - has_an_else = any(isinstance(link, ElseFlowLink) for link in links) + has_an_if = any(isinstance(link, IfFlowStepLink) for link in links) + has_an_else = any(isinstance(link, ElseFlowStepLink) for link in links) if has_an_if and not has_an_else: raise MissingElseBranchException(step.id, flow.id) diff --git a/rasa/validator.py b/rasa/validator.py index 750437463de6..d26809d6a775 100644 --- a/rasa/validator.py +++ b/rasa/validator.py @@ -7,7 +7,7 @@ from pypred import Predicate import rasa.core.training.story_conflict -from rasa.shared.core.flows.flow_step_links import IfFlowLink +from rasa.shared.core.flows.flow_step_links import IfFlowStepLink from rasa.shared.core.flows.steps.set_slots import SetSlotsFlowStep from rasa.shared.core.flows.steps.collect import CollectInformationFlowStep from rasa.shared.core.flows.steps.branch import BranchFlowStep @@ -637,7 +637,7 @@ def verify_predicates(self) -> bool: for step in flow.steps: if isinstance(step, BranchFlowStep): for link in step.next.links: - if isinstance(link, IfFlowLink): + if isinstance(link, IfFlowStepLink): predicate, all_good = Validator._construct_predicate( link.condition, step.id ) diff --git a/tests/dialogue_understanding/stack/frames/test_flow_frame.py b/tests/dialogue_understanding/stack/frames/test_flow_frame.py index 4df16555784c..f058d6959c86 100644 --- a/tests/dialogue_understanding/stack/frames/test_flow_frame.py +++ b/tests/dialogue_understanding/stack/frames/test_flow_frame.py @@ -6,7 +6,7 @@ UserFlowStackFrame, FlowStackFrameType, ) -from rasa.shared.core.flows.flow_step_links import FlowLinks +from rasa.shared.core.flows.flow_step_links import FlowStepLinks from rasa.shared.core.flows.steps.action import ActionFlowStep from rasa.shared.core.flows.flow_step_sequence import StepSequence from rasa.shared.core.flows.flow import Flow @@ -87,7 +87,7 @@ def test_flow_get_step(): custom_id="my_step", description=None, metadata={}, - next=FlowLinks(links=[]), + next=FlowStepLinks(links=[]), ) all_flows = FlowsList( flows=[ From b8a6c2bcced078ea9c34886cc504231420d3ee45 Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Thu, 26 Oct 2023 12:55:47 +0200 Subject: [PATCH 21/31] fixed empty step sequence validation not to use target method --- rasa/shared/core/flows/validation.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/rasa/shared/core/flows/validation.py b/rasa/shared/core/flows/validation.py index 2791b00ab763..1857206cf3e8 100644 --- a/rasa/shared/core/flows/validation.py +++ b/rasa/shared/core/flows/validation.py @@ -10,6 +10,7 @@ IfFlowStepLink, ElseFlowStepLink, ) +from rasa.shared.core.flows.flow_step_sequence import StepSequence from rasa.shared.core.flows.steps.constants import CONTINUE_STEP_PREFIX, DEFAULT_STEPS from rasa.shared.core.flows.steps.link import LinkFlowStep from rasa.shared.core.flows.flow import Flow @@ -198,7 +199,11 @@ def validate_no_empty_step_sequences(flow: Flow) -> None: """Validate that the flow does not have any empty step sequences.""" for step in flow.steps: for link in step.next.links: - if isinstance(link, BranchingFlowStepLink) and link.target is None: + if ( + isinstance(link, BranchingFlowStepLink) + and isinstance(link.target_reference, StepSequence) + and len(link.target_reference.child_steps) == 0 + ): raise EmptyStepSequenceException(flow.id, step.id) From 15aa90fce489ea68e62c17d44726df528c49cc71 Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Thu, 26 Oct 2023 13:10:06 +0200 Subject: [PATCH 22/31] ruff check --- rasa/shared/core/flows/steps/__init__.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/rasa/shared/core/flows/steps/__init__.py b/rasa/shared/core/flows/steps/__init__.py index cc211ac52c68..e195b3d7b4cd 100644 --- a/rasa/shared/core/flows/steps/__init__.py +++ b/rasa/shared/core/flows/steps/__init__.py @@ -1,6 +1,7 @@ from .action import ActionFlowStep from .branch import BranchFlowStep from .collect import CollectInformationFlowStep +from .continuation import ContinueFlowStep from .end import EndFlowStep from .generate_response import GenerateResponseFlowStep from .internal import InternalFlowStep @@ -8,3 +9,18 @@ from .set_slots import SetSlotsFlowStep from .start import StartFlowStep from .user_message import UserMessageStep + +# to make ruff happy and use the imported names +all_steps = [ + ActionFlowStep, + BranchFlowStep, + CollectInformationFlowStep, + ContinueFlowStep, + EndFlowStep, + GenerateResponseFlowStep, + InternalFlowStep, + LinkFlowStep, + SetSlotsFlowStep, + StartFlowStep, + UserMessageStep, +] From 1c131f03a865af9003a028ecca2990637ae57cbb Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Thu, 26 Oct 2023 13:54:31 +0200 Subject: [PATCH 23/31] improved docs, variable naming, interface adherence for all flow step classes --- rasa/shared/core/flows/flow.py | 32 +++++----- rasa/shared/core/flows/flow_step.py | 56 +++++++++-------- rasa/shared/core/flows/steps/action.py | 25 ++++---- rasa/shared/core/flows/steps/branch.py | 21 ++++--- rasa/shared/core/flows/steps/collect.py | 61 +++++++++---------- rasa/shared/core/flows/steps/continuation.py | 2 +- rasa/shared/core/flows/steps/end.py | 2 +- .../core/flows/steps/generate_response.py | 29 ++++----- rasa/shared/core/flows/steps/internal.py | 26 +++++--- rasa/shared/core/flows/steps/link.py | 27 ++++---- rasa/shared/core/flows/steps/set_slots.py | 29 ++++----- rasa/shared/core/flows/steps/start.py | 4 +- rasa/shared/core/flows/steps/user_message.py | 1 + 13 files changed, 165 insertions(+), 150 deletions(-) diff --git a/rasa/shared/core/flows/flow.py b/rasa/shared/core/flows/flow.py index a6dfe67bbf89..95d74839593e 100644 --- a/rasa/shared/core/flows/flow.py +++ b/rasa/shared/core/flows/flow.py @@ -37,21 +37,21 @@ class Flow: """The steps of the flow.""" @staticmethod - def from_json(flow_id: Text, flow_config: Dict[Text, Any]) -> Flow: - """Used to read flows from parsed YAML. + def from_json(flow_id: Text, data: Dict[Text, Any]) -> Flow: + """Create a Flow object from serialized data Args: - flow_config: The parsed YAML as a dictionary. + data: data for a Flow object in a serialized format. Returns: - The parsed flow. + A Flow object. """ - step_sequence = StepSequence.from_json(flow_config.get("steps")) + step_sequence = StepSequence.from_json(data.get("steps")) return Flow( id=flow_id, - name=flow_config.get("name", Flow.create_default_name(flow_id)), - description=flow_config.get("description"), + name=data.get("name", Flow.create_default_name(flow_id)), + description=data.get("description"), step_sequence=Flow.resolve_default_ids(step_sequence), ) @@ -100,10 +100,10 @@ def resolve_default_next(steps: List[FlowStep], is_root_sequence: bool) -> None: return step_sequence def as_json(self) -> Dict[Text, Any]: - """Returns the flow as a dictionary. + """Serialize the Flow object. Returns: - The flow as a dictionary. + The Flow object as serialized data. """ return { "id": self.id, @@ -146,11 +146,11 @@ def first_step_in_flow(self) -> Optional[FlowStep]: def previous_collect_steps( self, step_id: Optional[str] ) -> List[CollectInformationFlowStep]: - """Returns the collect information steps asked before the given step. + """Return the CollectInformationFlowSteps asked before the given step. - CollectInformationSteps are returned roughly in reverse order, i.e. the first - collect information in the list is the one asked last. But due to circles - in the flow the order is not guaranteed to be exactly reverse. + CollectInformationFlowSteps are returned roughly in reverse order, + i.e. the first step in the list is the one that was asked last. However, + due to circles in the flow, the order is not guaranteed to be exactly reverse. """ def _previously_asked_collect( @@ -187,11 +187,11 @@ def _previously_asked_collect( @property def is_rasa_default_flow(self) -> bool: - """Test whether something is a rasa default flow.""" + """Test whether the flow is a rasa default flow.""" return self.id.startswith(RASA_DEFAULT_FLOW_PATTERN_PREFIX) def get_collect_steps(self) -> List[CollectInformationFlowStep]: - """Return the collect information steps of the flow.""" + """Return all CollectInformationFlowSteps in the flow.""" collect_steps = [] for step in self.steps: if isinstance(step, CollectInformationFlowStep): @@ -200,7 +200,7 @@ def get_collect_steps(self) -> List[CollectInformationFlowStep]: @property def steps(self) -> List[FlowStep]: - """Returns the steps of the flow.""" + """Return the steps of the flow.""" return self.step_sequence.steps @cached_property diff --git a/rasa/shared/core/flows/flow_step.py b/rasa/shared/core/flows/flow_step.py index 482224ca715d..b39a4ed8a73e 100644 --- a/rasa/shared/core/flows/flow_step.py +++ b/rasa/shared/core/flows/flow_step.py @@ -18,14 +18,14 @@ structlogger = structlog.get_logger() -def step_from_json(flow_step_config: Dict[Text, Any]) -> FlowStep: - """Used to read flow steps from parsed YAML. +def step_from_json(data: Dict[Text, Any]) -> FlowStep: + """Create a specific FlowStep from serialized data. Args: - flow_step_config: The parsed YAML as a dictionary. + data: data for a specific FlowStep object in a serialized data format. Returns: - The parsed flow step. + An instance of a specific FlowStep class. """ from rasa.shared.core.flows.steps import ( ActionFlowStep, @@ -37,25 +37,25 @@ def step_from_json(flow_step_config: Dict[Text, Any]) -> FlowStep: BranchFlowStep, ) - if "action" in flow_step_config: - return ActionFlowStep.from_json(flow_step_config) - if "intent" in flow_step_config: - return UserMessageStep.from_json(flow_step_config) - if "collect" in flow_step_config: - return CollectInformationFlowStep.from_json(flow_step_config) - if "link" in flow_step_config: - return LinkFlowStep.from_json(flow_step_config) - if "set_slots" in flow_step_config: - return SetSlotsFlowStep.from_json(flow_step_config) - if "generation_prompt" in flow_step_config: - return GenerateResponseFlowStep.from_json(flow_step_config) + if "action" in data: + return ActionFlowStep.from_json(data) + if "intent" in data: + return UserMessageStep.from_json(data) + if "collect" in data: + return CollectInformationFlowStep.from_json(data) + if "link" in data: + return LinkFlowStep.from_json(data) + if "set_slots" in data: + return SetSlotsFlowStep.from_json(data) + if "generation_prompt" in data: + return GenerateResponseFlowStep.from_json(data) else: - return BranchFlowStep.from_json(flow_step_config) + return BranchFlowStep.from_json(data) @dataclass class FlowStep: - """Represents the configuration of a flow step.""" + """A single step in a flow.""" custom_id: Optional[Text] """The id of the flow step.""" @@ -91,33 +91,35 @@ def _from_json(cls, flow_step_config: Dict[Text, Any]) -> FlowStep: ) def as_json(self) -> Dict[Text, Any]: - """Returns the flow step as a dictionary. + """Serialize the FlowStep object. Returns: - The flow step as a dictionary. + The FlowStep as serialized data. """ - dump = {"next": self.next.as_json(), "id": self.id} + data = {"next": self.next.as_json(), "id": self.id} if self.description: - dump["description"] = self.description + data["description"] = self.description if self.metadata: - dump["metadata"] = self.metadata - return dump + data["metadata"] = self.metadata + return data def steps_in_tree(self) -> Generator[FlowStep, None, None]: - """Returns the steps in the tree of the flow step.""" + """Recursively generates the steps in the tree.""" yield self yield from self.next.steps_in_tree() @property def id(self) -> Text: """Returns the id of the flow step.""" - return self.custom_id or self.default_id() + return self.custom_id or self.default_id + @property def default_id(self) -> str: """Returns the default id of the flow step.""" - return f"{self.idx}_{self.default_id_postfix()}" + return f"{self.idx}_{self.default_id_postfix}" + @property def default_id_postfix(self) -> str: """Returns the default id postfix of the flow step.""" raise NotImplementedError() diff --git a/rasa/shared/core/flows/steps/action.py b/rasa/shared/core/flows/steps/action.py index f3d6950d50dc..7e837fa6b169 100644 --- a/rasa/shared/core/flows/steps/action.py +++ b/rasa/shared/core/flows/steps/action.py @@ -9,37 +9,38 @@ @dataclass class ActionFlowStep(FlowStep): - """Represents the configuration of an action flow step.""" + """A flow step that that defines an action to be executed.""" action: Text """The action of the flow step.""" @classmethod - def from_json(cls, flow_step_config: Dict[Text, Any]) -> ActionFlowStep: - """Used to read flow steps from parsed YAML. + def from_json(cls, data: Dict[Text, Any]) -> ActionFlowStep: + """Create an ActionFlowStep object from serialized data Args: - flow_step_config: The parsed YAML as a dictionary. + data: data for an ActionFlowStep object in a serialized format Returns: - The parsed flow step. + An ActionFlowStep object """ - base = super()._from_json(flow_step_config) + base = super()._from_json(data) return ActionFlowStep( - action=flow_step_config.get("action", ""), + action=data["action"], **base.__dict__, ) def as_json(self) -> Dict[Text, Any]: - """Returns the flow step as a dictionary. + """Serialize the ActionFlowStep Returns: - The flow step as a dictionary. + The ActionFlowStep object as serialized data. """ - dump = super().as_json() - dump["action"] = self.action - return dump + data = super().as_json() + data["action"] = self.action + return data + @property def default_id_postfix(self) -> str: return self.action diff --git a/rasa/shared/core/flows/steps/branch.py b/rasa/shared/core/flows/steps/branch.py index 86d5382782c8..4220009361a9 100644 --- a/rasa/shared/core/flows/steps/branch.py +++ b/rasa/shared/core/flows/steps/branch.py @@ -5,33 +5,36 @@ from rasa.shared.core.flows.flow_step import FlowStep - +# TODO: this is ambiguous, even steps that only have one static +# follow up might become branch flow steps +# validator also misuses it!!! @dataclass class BranchFlowStep(FlowStep): - """Represents the configuration of a branch flow step.""" + """An unspecific FlowStep that has a next attribute.""" @classmethod - def from_json(cls, flow_step_config: Dict[Text, Any]) -> BranchFlowStep: - """Used to read flow steps from parsed YAML. + def from_json(cls, data: Dict[Text, Any]) -> BranchFlowStep: + """Create a BranchFlowStep object from serialized data. Args: - flow_step_config: The parsed YAML as a dictionary. + data: data for a BranchFlowStep object in a serialized format Returns: - The parsed flow step. + A BranchFlowStep object. """ - base = super()._from_json(flow_step_config) + base = super()._from_json(data) return BranchFlowStep(**base.__dict__) def as_json(self) -> Dict[Text, Any]: - """Returns the flow step as a dictionary. + """Serialize the BranchFlowStep object Returns: - The flow step as a dictionary. + the BranchFlowStep object as serialized data. """ dump = super().as_json() return dump + @property def default_id_postfix(self) -> str: """Returns the default id postfix of the flow step.""" return "branch" diff --git a/rasa/shared/core/flows/steps/collect.py b/rasa/shared/core/flows/steps/collect.py index c4b5fe3f5fb5..1c197d85a45c 100644 --- a/rasa/shared/core/flows/steps/collect.py +++ b/rasa/shared/core/flows/steps/collect.py @@ -8,7 +8,7 @@ @dataclass class SlotRejection: - """A slot rejection.""" + """A pair of validation condition and an utterance for the case of failure.""" if_: str """The condition that should be checked.""" @@ -16,25 +16,25 @@ class SlotRejection: """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. + def from_dict(data: Dict[Text, Any]) -> SlotRejection: + """Create a SlotRejection object from serialized data Args: - rejection_config: The parsed YAML as a dictionary. + data: data for a SlotRejection object in a serialized format Returns: - The parsed slot rejection. + A SlotRejection object """ return SlotRejection( - if_=rejection_config["if"], - utter=rejection_config["utter"], + if_=data["if"], + utter=data["utter"], ) def as_dict(self) -> Dict[Text, Any]: - """Returns the slot rejection as a dictionary. + """Serialize the SlotRejection object Returns: - The slot rejection as a dictionary. + the SlotRejection object as serialized data """ return { "if": self.if_, @@ -44,7 +44,7 @@ def as_dict(self) -> Dict[Text, Any]: @dataclass class CollectInformationFlowStep(FlowStep): - """Represents the configuration of a collect information flow step.""" + """A flow step for asking the user for information to fill a specific slot.""" collect: Text """The collect information of the flow step.""" @@ -58,45 +58,44 @@ class CollectInformationFlowStep(FlowStep): """Determines whether to reset the slot value at the end of the flow.""" @classmethod - def from_json(cls, flow_step_config: Dict[Text, Any]) -> CollectInformationFlowStep: - """Used to read flow steps from parsed YAML. + def from_json(cls, data: Dict[Text, Any]) -> CollectInformationFlowStep: + """Create a CollectInformationFlowStep object from serialized data Args: - flow_step_config: The parsed YAML as a dictionary. + data: data for a CollectInformationFlowStep object in a serialized format Returns: - The parsed flow step. + A CollectInformationFlowStep object """ - base = super()._from_json(flow_step_config) + base = super()._from_json(data) return CollectInformationFlowStep( - collect=flow_step_config["collect"], - utter=flow_step_config.get( - "utter", f"utter_ask_{flow_step_config['collect']}" - ), - ask_before_filling=flow_step_config.get("ask_before_filling", False), - reset_after_flow_ends=flow_step_config.get("reset_after_flow_ends", True), + collect=data["collect"], + utter=data.get("utter", f"utter_ask_{data['collect']}"), + ask_before_filling=data.get("ask_before_filling", False), + reset_after_flow_ends=data.get("reset_after_flow_ends", True), rejections=[ SlotRejection.from_dict(rejection) - for rejection in flow_step_config.get("rejections", []) + for rejection in data.get("rejections", []) ], **base.__dict__, ) def as_json(self) -> Dict[Text, Any]: - """Returns the flow step as a dictionary. + """Serialize the CollectInformationFlowStep object. Returns: - The flow step as a dictionary. + the CollectInformationFlowStep object as serialized data """ - dump = super().as_json() - dump["collect"] = self.collect - dump["utter"] = self.utter - dump["ask_before_filling"] = self.ask_before_filling - dump["reset_after_flow_ends"] = self.reset_after_flow_ends - dump["rejections"] = [rejection.as_dict() for rejection in self.rejections] + data = super().as_json() + data["collect"] = self.collect + data["utter"] = self.utter + data["ask_before_filling"] = self.ask_before_filling + data["reset_after_flow_ends"] = self.reset_after_flow_ends + data["rejections"] = [rejection.as_dict() for rejection in self.rejections] - return dump + return data + @property def default_id_postfix(self) -> str: """Returns the default id postfix of the flow step.""" return f"collect_{self.collect}" diff --git a/rasa/shared/core/flows/steps/continuation.py b/rasa/shared/core/flows/steps/continuation.py index ae561e70803e..84711e35404e 100644 --- a/rasa/shared/core/flows/steps/continuation.py +++ b/rasa/shared/core/flows/steps/continuation.py @@ -9,7 +9,7 @@ @dataclass class ContinueFlowStep(InternalFlowStep): - """Represents the configuration of a continue-step flow step.""" + """A flow step that is dynamically introduced to jump to other flow steps.""" def __init__(self, next: str) -> None: """Initializes a continue-step flow step.""" diff --git a/rasa/shared/core/flows/steps/end.py b/rasa/shared/core/flows/steps/end.py index 331f78125851..4e615f5d8487 100644 --- a/rasa/shared/core/flows/steps/end.py +++ b/rasa/shared/core/flows/steps/end.py @@ -9,7 +9,7 @@ @dataclass class EndFlowStep(InternalFlowStep): - """Represents the configuration of an end to a flow.""" + """A dynamically added flow step that marks the end of a flow.""" def __init__(self) -> None: """Initializes an end flow step.""" diff --git a/rasa/shared/core/flows/steps/generate_response.py b/rasa/shared/core/flows/steps/generate_response.py index 45bf13e9def9..d9720c7ad800 100644 --- a/rasa/shared/core/flows/steps/generate_response.py +++ b/rasa/shared/core/flows/steps/generate_response.py @@ -21,7 +21,7 @@ @dataclass class GenerateResponseFlowStep(FlowStep): - """Represents the configuration of a step prompting an LLM.""" + """A flow step that creates a free-form bot utterance using an LLM.""" generation_prompt: Text """The prompt template of the flow step.""" @@ -29,34 +29,34 @@ class GenerateResponseFlowStep(FlowStep): """The LLM configuration of the flow step.""" @classmethod - def from_json(cls, flow_step_config: Dict[Text, Any]) -> GenerateResponseFlowStep: - """Used to read flow steps from parsed YAML. + def from_json(cls, data: Dict[Text, Any]) -> GenerateResponseFlowStep: + """Create a GenerateResponseFlowStep from serialized data Args: - flow_step_config: The parsed YAML as a dictionary. + data: data for a GenerateResponseFlowStep in a serialized format Returns: - The parsed flow step. + A GenerateResponseFlowStep object """ - base = super()._from_json(flow_step_config) + base = super()._from_json(data) return GenerateResponseFlowStep( - generation_prompt=flow_step_config.get("generation_prompt", ""), - llm_config=flow_step_config.get("llm", None), + generation_prompt=data["generation_prompt"], + llm_config=data.get("llm"), **base.__dict__, ) def as_json(self) -> Dict[Text, Any]: - """Returns the flow step as a dictionary. + """Serialize the GenerateResponseFlowStep object. Returns: - The flow step as a dictionary. + the GenerateResponseFlowStep object as serialized data. """ - dump = super().as_json() - dump["generation_prompt"] = self.generation_prompt + data = super().as_json() + data["generation_prompt"] = self.generation_prompt if self.llm_config: - dump["llm"] = self.llm_config + data["llm"] = self.llm_config - return dump + return data def generate(self, tracker: DialogueStateTracker) -> Optional[Text]: """Generates a response for the given tracker. @@ -91,5 +91,6 @@ def generate(self, tracker: DialogueStateTracker) -> Optional[Text]: ) return None + @property def default_id_postfix(self) -> str: return "generate" diff --git a/rasa/shared/core/flows/steps/internal.py b/rasa/shared/core/flows/steps/internal.py index ab88da32cb0c..d0b5124d101b 100644 --- a/rasa/shared/core/flows/steps/internal.py +++ b/rasa/shared/core/flows/steps/internal.py @@ -6,28 +6,34 @@ class InternalFlowStep(FlowStep): - """Represents the configuration of a built-in flow step. + """A superclass for built-in flow steps. - Built in flow steps are required to manage the lifecycle of a + Built-in flow steps are required to manage the lifecycle of a flow and are not intended to be used by users. """ @classmethod - def from_json(cls, flow_step_config: Dict[Text, Any]) -> InternalFlowStep: - """Used to read flow steps from parsed JSON. + def from_json(cls, data: Dict[Text, Any]) -> InternalFlowStep: + """Create an InternalFlowStep object from serialized data. Args: - flow_step_config: The parsed JSON as a dictionary. + data: data for an InternalFlowStep in a serialized format Returns: - The parsed flow step. + Raises because InternalFlowSteps are not serialized or de-serialized. """ - raise ValueError("A start step cannot be parsed.") + raise ValueError( + "Internal flow steps are ephemeral and are not to be serialized " + "or de-serialized." + ) def as_json(self) -> Dict[Text, Any]: - """Returns the flow step as a dictionary. + """Serialize the InternalFlowStep object Returns: - The flow step as a dictionary. + Raises because InternalFlowSteps are not serialized or de-serialized. """ - raise ValueError("A start step cannot be dumped.") + raise ValueError( + "Internal flow steps are ephemeral and are not to be serialized " + "or de-serialized." + ) diff --git a/rasa/shared/core/flows/steps/link.py b/rasa/shared/core/flows/steps/link.py index 5eb4ca5c2060..6f94e18f8acc 100644 --- a/rasa/shared/core/flows/steps/link.py +++ b/rasa/shared/core/flows/steps/link.py @@ -8,37 +8,38 @@ @dataclass class LinkFlowStep(FlowStep): - """Represents the configuration of a link flow step.""" + """A flow step at the end of a flow that links to and starts another flow.""" link: Text - """The link of the flow step.""" + """The id of the flow that should be started subsequently.""" @classmethod - def from_json(cls, flow_step_config: Dict[Text, Any]) -> LinkFlowStep: - """Used to read flow steps from parsed YAML. + def from_json(cls, data: Dict[Text, Any]) -> LinkFlowStep: + """Create a LinkFlowStep from serialized data Args: - flow_step_config: The parsed YAML as a dictionary. + data: data for a LinkFlowStep in a serialized format Returns: - The parsed flow step. + a LinkFlowStep object """ - base = super()._from_json(flow_step_config) + base = super()._from_json(data) return LinkFlowStep( - link=flow_step_config.get("link", ""), + link=data.get("link", ""), **base.__dict__, ) def as_json(self) -> Dict[Text, Any]: - """Returns the flow step as a dictionary. + """Serialize the LinkFlowStep object Returns: - The flow step as a dictionary. + the LinkFlowStep object as serialized data. """ - dump = super().as_json() - dump["link"] = self.link - return dump + data = super().as_json() + data["link"] = self.link + return data + @property def default_id_postfix(self) -> str: """Returns the default id postfix of the flow step.""" return f"link_{self.link}" diff --git a/rasa/shared/core/flows/steps/set_slots.py b/rasa/shared/core/flows/steps/set_slots.py index b424cf41075b..b8fbdbfbc113 100644 --- a/rasa/shared/core/flows/steps/set_slots.py +++ b/rasa/shared/core/flows/steps/set_slots.py @@ -8,26 +8,26 @@ @dataclass class SetSlotsFlowStep(FlowStep): - """Represents the configuration of a set_slots flow step.""" + """A flow step that sets one or multiple slots.""" slots: List[Dict[str, Any]] - """Slots to set of the flow step.""" + """Slots and their values to set in the flow step.""" @classmethod - def from_json(cls, flow_step_config: Dict[Text, Any]) -> SetSlotsFlowStep: - """Used to read flow steps from parsed YAML. + def from_json(cls, data: Dict[Text, Any]) -> SetSlotsFlowStep: + """Create a SetSlotsFlowStep from serialized data Args: - flow_step_config: The parsed YAML as a dictionary. + data: data for a SetSlotsFlowStep in a serialized format Returns: - The parsed flow step. + a SetSlotsFlowStep object """ - base = super()._from_json(flow_step_config) + base = super()._from_json(data) slots = [ {"key": k, "value": v} - for slot in flow_step_config.get("set_slots", []) - for k, v in slot.items() + for slot_sets in data["set_slots"] + for k, v in slot_sets.items() ] return SetSlotsFlowStep( slots=slots, @@ -35,15 +35,16 @@ def from_json(cls, flow_step_config: Dict[Text, Any]) -> SetSlotsFlowStep: ) def as_json(self) -> Dict[Text, Any]: - """Returns the flow step as a dictionary. + """Serialize the SetSlotsFlowStep object Returns: - The flow step as a dictionary. + the SetSlotsFlowStep object as serialized data """ - dump = super().as_json() - dump["set_slots"] = [{slot["key"]: slot["value"]} for slot in self.slots] - return dump + data = super().as_json() + data["set_slots"] = [{slot["key"]: slot["value"]} for slot in self.slots] + return data + @property def default_id_postfix(self) -> str: """Returns the default id postfix of the flow step.""" return "set_slots" diff --git a/rasa/shared/core/flows/steps/start.py b/rasa/shared/core/flows/steps/start.py index a1336da8cfff..7b06bf1c7841 100644 --- a/rasa/shared/core/flows/steps/start.py +++ b/rasa/shared/core/flows/steps/start.py @@ -14,13 +14,13 @@ @dataclass class StartFlowStep(InternalFlowStep): - """Represents the configuration of a start flow step.""" + """A dynamically added flow step that represents the beginning of a flow.""" def __init__(self, start_step_id: Optional[Text]) -> None: """Initializes a start flow step. Args: - start_step: The step to start the flow from. + start_step_id: The step id of the first step of the flow """ if start_step_id is not None: links: List[FlowStepLink] = [StaticFlowStepLink(start_step_id)] diff --git a/rasa/shared/core/flows/steps/user_message.py b/rasa/shared/core/flows/steps/user_message.py index 559b0b9d7048..6ab04afe91a6 100644 --- a/rasa/shared/core/flows/steps/user_message.py +++ b/rasa/shared/core/flows/steps/user_message.py @@ -136,6 +136,7 @@ def is_triggered(self, tracker: DialogueStateTracker) -> bool: for trigger_condition in self.trigger_conditions ) + @property def default_id_postfix(self) -> str: """Returns the default id postfix of the flow step.""" return "intent" From 5464980ff43c85f779dc510ec19690a400b2dda4 Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Thu, 26 Oct 2023 14:05:15 +0200 Subject: [PATCH 24/31] added type annotations --- rasa/shared/core/flows/flow_step.py | 2 +- rasa/shared/core/flows/validation.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rasa/shared/core/flows/flow_step.py b/rasa/shared/core/flows/flow_step.py index b39a4ed8a73e..f8f5330d2411 100644 --- a/rasa/shared/core/flows/flow_step.py +++ b/rasa/shared/core/flows/flow_step.py @@ -96,7 +96,7 @@ def as_json(self) -> Dict[Text, Any]: Returns: The FlowStep as serialized data. """ - data = {"next": self.next.as_json(), "id": self.id} + data: Dict[Text, Any] = {"next": self.next.as_json(), "id": self.id} if self.description: data["description"] = self.description diff --git a/rasa/shared/core/flows/validation.py b/rasa/shared/core/flows/validation.py index 1857206cf3e8..2f934284a580 100644 --- a/rasa/shared/core/flows/validation.py +++ b/rasa/shared/core/flows/validation.py @@ -238,7 +238,7 @@ def validate_all_steps_next_property(flow: Flow) -> None: raise MissingNextLinkException(step.id, flow.id) -def validate_all_next_ids_are_available_steps(flow) -> None: +def validate_all_next_ids_are_available_steps(flow: Flow) -> None: """Validates that all next links point to existing steps.""" available_steps = {step.id for step in flow.steps} | DEFAULT_STEPS for step in flow.steps: @@ -247,7 +247,7 @@ def validate_all_next_ids_are_available_steps(flow) -> None: raise UnresolvedFlowStepIdException(link.target, flow.id, step.id) -def validate_all_steps_can_be_reached(flow) -> None: +def validate_all_steps_can_be_reached(flow: Flow) -> None: """Validates that all steps can be reached from the start step.""" def _reachable_steps( From 1861c196f7e5a0a991cc082d3e153428bd8e763b Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Thu, 26 Oct 2023 14:13:17 +0200 Subject: [PATCH 25/31] improved docs, naming in the flow step sequence --- rasa/shared/core/flows/flow.py | 8 ++--- rasa/shared/core/flows/flow_step_links.py | 32 +++++++++---------- rasa/shared/core/flows/flow_step_sequence.py | 24 +++++++------- rasa/shared/core/flows/validation.py | 4 +-- .../stack/frames/test_flow_frame.py | 12 +++---- 5 files changed, 41 insertions(+), 39 deletions(-) diff --git a/rasa/shared/core/flows/flow.py b/rasa/shared/core/flows/flow.py index 95d74839593e..fd806ad55477 100644 --- a/rasa/shared/core/flows/flow.py +++ b/rasa/shared/core/flows/flow.py @@ -20,7 +20,7 @@ from rasa.shared.core.flows.steps.start import StartFlowStep from rasa.shared.core.flows.steps.collect import CollectInformationFlowStep from rasa.shared.core.flows.steps.link import LinkFlowStep -from rasa.shared.core.flows.flow_step_sequence import StepSequence +from rasa.shared.core.flows.flow_step_sequence import FlowStepSequence @dataclass @@ -33,7 +33,7 @@ class Flow: """The human-readable name of the flow.""" description: Optional[Text] """The description of the flow.""" - step_sequence: StepSequence + step_sequence: FlowStepSequence """The steps of the flow.""" @staticmethod @@ -46,7 +46,7 @@ def from_json(flow_id: Text, data: Dict[Text, Any]) -> Flow: Returns: A Flow object. """ - step_sequence = StepSequence.from_json(data.get("steps")) + step_sequence = FlowStepSequence.from_json(data.get("steps")) return Flow( id=flow_id, @@ -61,7 +61,7 @@ def create_default_name(flow_id: str) -> str: return flow_id.replace("_", " ").replace("-", " ") @staticmethod - def resolve_default_ids(step_sequence: StepSequence) -> StepSequence: + def resolve_default_ids(step_sequence: FlowStepSequence) -> FlowStepSequence: """Resolves the default ids of all steps in the sequence. If a step does not have an id, a default id is assigned to it based diff --git a/rasa/shared/core/flows/flow_step_links.py b/rasa/shared/core/flows/flow_step_links.py index eeff69c28348..915333c2ca73 100644 --- a/rasa/shared/core/flows/flow_step_links.py +++ b/rasa/shared/core/flows/flow_step_links.py @@ -7,7 +7,7 @@ from rasa.shared.core.flows.flow_step import FlowStep if TYPE_CHECKING: - from rasa.shared.core.flows.flow_step_sequence import StepSequence + from rasa.shared.core.flows.flow_step_sequence import FlowStepSequence @dataclass @@ -107,21 +107,21 @@ def child_steps(self) -> List[FlowStep]: @dataclass class BranchingFlowStepLink(FlowStepLink): - target_reference: Union[Text, StepSequence] + target_reference: Union[Text, FlowStepSequence] """The id of the linked step or a sequence of steps.""" def steps_in_tree(self) -> Generator[FlowStep, None, None]: """Recursively generates the steps in the tree.""" - from rasa.shared.core.flows.flow_step_sequence import StepSequence + from rasa.shared.core.flows.flow_step_sequence import FlowStepSequence - if isinstance(self.target_reference, StepSequence): + if isinstance(self.target_reference, FlowStepSequence): yield from self.target_reference.steps def child_steps(self) -> List[FlowStep]: """Returns the steps of the linked flow step sequence if any.""" - from rasa.shared.core.flows.flow_step_sequence import StepSequence + from rasa.shared.core.flows.flow_step_sequence import FlowStepSequence - if isinstance(self.target_reference, StepSequence): + if isinstance(self.target_reference, FlowStepSequence): return self.target_reference.child_steps else: return [] @@ -129,9 +129,9 @@ def child_steps(self) -> List[FlowStep]: @property def target(self) -> Text: """Return the target flow step id.""" - from rasa.shared.core.flows.flow_step_sequence import StepSequence + from rasa.shared.core.flows.flow_step_sequence import FlowStepSequence - if isinstance(self.target_reference, StepSequence): + if isinstance(self.target_reference, FlowStepSequence): if first := self.target_reference.first(): return first.id else: @@ -175,13 +175,13 @@ def from_json(data: Dict[Text, Any]) -> IfFlowStepLink: Returns: An IfFlowStepLink object. """ - from rasa.shared.core.flows.flow_step_sequence import StepSequence + from rasa.shared.core.flows.flow_step_sequence import FlowStepSequence if isinstance(data["then"], str): return IfFlowStepLink(target_reference=data["then"], condition=data["if"]) else: return IfFlowStepLink( - target_reference=StepSequence.from_json(data["then"]), + target_reference=FlowStepSequence.from_json(data["then"]), condition=data["if"], ) @@ -191,12 +191,12 @@ def as_json(self) -> Dict[Text, Any]: Returns: the IfFlowStepLink object as serialized data. """ - from rasa.shared.core.flows.flow_step_sequence import StepSequence + from rasa.shared.core.flows.flow_step_sequence import FlowStepSequence return { "if": self.condition, "then": self.target_reference.as_json() - if isinstance(self.target_reference, StepSequence) + if isinstance(self.target_reference, FlowStepSequence) else self.target_reference, } @@ -215,13 +215,13 @@ def from_json(data: Dict[Text, Any]) -> ElseFlowStepLink: Returns: An ElseFlowStepLink """ - from rasa.shared.core.flows.flow_step_sequence import StepSequence + from rasa.shared.core.flows.flow_step_sequence import FlowStepSequence if isinstance(data["else"], str): return ElseFlowStepLink(target_reference=data["else"]) else: return ElseFlowStepLink( - target_reference=StepSequence.from_json(data["else"]) + target_reference=FlowStepSequence.from_json(data["else"]) ) def as_json(self) -> Dict[Text, Any]: @@ -230,11 +230,11 @@ def as_json(self) -> Dict[Text, Any]: Returns: The ElseFlowStepLink as serialized data. """ - from rasa.shared.core.flows.flow_step_sequence import StepSequence + from rasa.shared.core.flows.flow_step_sequence import FlowStepSequence return { "else": self.target_reference.as_json() - if isinstance(self.target_reference, StepSequence) + if isinstance(self.target_reference, FlowStepSequence) else self.target_reference } diff --git a/rasa/shared/core/flows/flow_step_sequence.py b/rasa/shared/core/flows/flow_step_sequence.py index 138d11b7325b..e08d859042dd 100644 --- a/rasa/shared/core/flows/flow_step_sequence.py +++ b/rasa/shared/core/flows/flow_step_sequence.py @@ -8,29 +8,31 @@ @dataclass -class StepSequence: +class FlowStepSequence: + """A Sequence of flow steps.""" + child_steps: List[FlowStep] @staticmethod - def from_json(steps_config: List[Dict[Text, Any]]) -> StepSequence: - """Used to read steps from parsed YAML. + def from_json(data: List[Dict[Text, Any]]) -> FlowStepSequence: + """Create a StepSequence object from serialized data Args: - steps_config: The parsed YAML as a dictionary. + data: data for a StepSequence in a serialized format Returns: - The parsed steps. + A StepSequence object including its flow step objects. """ - flow_steps: List[FlowStep] = [step_from_json(config) for config in steps_config] + flow_steps: List[FlowStep] = [step_from_json(config) for config in data] - return StepSequence(child_steps=flow_steps) + return FlowStepSequence(child_steps=flow_steps) def as_json(self) -> List[Dict[Text, Any]]: - """Returns the steps as a dictionary. + """Serialize the StepSequence object and contained FlowStep objects Returns: - The steps as a dictionary. + the StepSequence and its FlowSteps as serialized data """ return [ step.as_json() @@ -40,7 +42,7 @@ def as_json(self) -> List[Dict[Text, Any]]: @property def steps(self) -> List[FlowStep]: - """Returns the steps of the flow.""" + """Return all steps in this step sequence and their sub steps.""" return [ step for child_step in self.child_steps @@ -48,7 +50,7 @@ def steps(self) -> List[FlowStep]: ] def first(self) -> Optional[FlowStep]: - """Returns the first step of the sequence.""" + """Return the first step of the sequence.""" if len(self.child_steps) == 0: return None return self.child_steps[0] diff --git a/rasa/shared/core/flows/validation.py b/rasa/shared/core/flows/validation.py index 2f934284a580..326797ed646d 100644 --- a/rasa/shared/core/flows/validation.py +++ b/rasa/shared/core/flows/validation.py @@ -10,7 +10,7 @@ IfFlowStepLink, ElseFlowStepLink, ) -from rasa.shared.core.flows.flow_step_sequence import StepSequence +from rasa.shared.core.flows.flow_step_sequence import FlowStepSequence from rasa.shared.core.flows.steps.constants import CONTINUE_STEP_PREFIX, DEFAULT_STEPS from rasa.shared.core.flows.steps.link import LinkFlowStep from rasa.shared.core.flows.flow import Flow @@ -201,7 +201,7 @@ def validate_no_empty_step_sequences(flow: Flow) -> None: for link in step.next.links: if ( isinstance(link, BranchingFlowStepLink) - and isinstance(link.target_reference, StepSequence) + and isinstance(link.target_reference, FlowStepSequence) and len(link.target_reference.child_steps) == 0 ): raise EmptyStepSequenceException(flow.id, step.id) diff --git a/tests/dialogue_understanding/stack/frames/test_flow_frame.py b/tests/dialogue_understanding/stack/frames/test_flow_frame.py index f058d6959c86..2825c3cca6f7 100644 --- a/tests/dialogue_understanding/stack/frames/test_flow_frame.py +++ b/tests/dialogue_understanding/stack/frames/test_flow_frame.py @@ -8,7 +8,7 @@ ) from rasa.shared.core.flows.flow_step_links import FlowStepLinks from rasa.shared.core.flows.steps.action import ActionFlowStep -from rasa.shared.core.flows.flow_step_sequence import StepSequence +from rasa.shared.core.flows.flow_step_sequence import FlowStepSequence from rasa.shared.core.flows.flow import Flow from rasa.shared.core.flows.flows_list import FlowsList @@ -55,7 +55,7 @@ def test_flow_get_flow(): frame = UserFlowStackFrame(frame_id="test", flow_id="foo", step_id="bar") flow = Flow( id="foo", - step_sequence=StepSequence(child_steps=[]), + step_sequence=FlowStepSequence(child_steps=[]), name="foo flow", description="foo flow description", ) @@ -69,7 +69,7 @@ def test_flow_get_flow_non_existant_id(): flows=[ Flow( id="foo", - step_sequence=StepSequence(child_steps=[]), + step_sequence=FlowStepSequence(child_steps=[]), name="foo flow", description="foo flow description", ) @@ -93,7 +93,7 @@ def test_flow_get_step(): flows=[ Flow( id="foo", - step_sequence=StepSequence(child_steps=[step]), + step_sequence=FlowStepSequence(child_steps=[step]), name="foo flow", description="foo flow description", ) @@ -108,7 +108,7 @@ def test_flow_get_step_non_existant_id(): flows=[ Flow( id="foo", - step_sequence=StepSequence(child_steps=[]), + step_sequence=FlowStepSequence(child_steps=[]), name="foo flow", description="foo flow description", ) @@ -124,7 +124,7 @@ def test_flow_get_step_non_existant_flow_id(): flows=[ Flow( id="foo", - step_sequence=StepSequence(child_steps=[]), + step_sequence=FlowStepSequence(child_steps=[]), name="foo flow", description="foo flow description", ) From c5d8e51d6b02bd87dbfa34716340032cbf1bd2d1 Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Thu, 26 Oct 2023 14:23:12 +0200 Subject: [PATCH 26/31] improved docs and naming of the FlowsList --- rasa/server.py | 2 +- rasa/shared/core/flows/flows_list.py | 46 +++++++++++++--------------- 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/rasa/server.py b/rasa/server.py index 33d621457895..2a4ab6e6cb12 100644 --- a/rasa/server.py +++ b/rasa/server.py @@ -1378,7 +1378,7 @@ async def get_flows(request: Request) -> HTTPResponse: """Get all the flows currently stored by the agent.""" processor = app.ctx.agent.processor flows = processor.get_flows() - return response.json(flows.as_json()) + return response.json(flows.as_json_list()) @app.get("/domain") @requires_auth(app, auth_token) diff --git a/rasa/shared/core/flows/flows_list.py b/rasa/shared/core/flows/flows_list.py index c57cbd879489..841cbdfb1ba8 100644 --- a/rasa/shared/core/flows/flows_list.py +++ b/rasa/shared/core/flows/flows_list.py @@ -2,24 +2,24 @@ from typing import List, Generator, Any, Optional, Dict, Text, Set -import rasa.shared +import rasa.shared.utils.io from rasa.shared.core.flows.flow import Flow from rasa.shared.core.flows.validation import validate_flow class FlowsList: - """Represents the configuration of a list of flow. + """A collection of flows. - We need this class to be able to fingerprint the flows configuration. - Fingerprinting is needed to make sure that the model is retrained if the - flows configuration changes. + This class defines a number of methods that are executed across the available flows, + such as fingerprinting (for retraining caching), collecting flows with + specific attributes or collecting all utterances across all flows. """ def __init__(self, flows: List[Flow]) -> None: - """Initializes the configuration of flows. + """Initializes the FlowsList object. Args: - flows: The flows to be configured. + flows: The flows for this collection. """ self.underlying_flows = flows @@ -28,7 +28,7 @@ def __iter__(self) -> Generator[Flow, None, None]: yield from self.underlying_flows def __eq__(self, other: Any) -> bool: - """Compares the flows.""" + """Compares this FlowsList to another one.""" return ( isinstance(other, FlowsList) and self.underlying_flows == other.underlying_flows @@ -39,40 +39,38 @@ def is_empty(self) -> bool: return len(self.underlying_flows) == 0 @classmethod - def from_json( - cls, flows_configs: Optional[Dict[Text, Dict[Text, Any]]] - ) -> FlowsList: - """Used to read flows from parsed YAML. + def from_json(cls, data: Optional[Dict[Text, Dict[Text, Any]]]) -> FlowsList: + """Create a FlowsList object from serialized data Args: - flows_configs: The parsed YAML as a dictionary. + data: data for a FlowsList in a serialized format Returns: - The parsed flows. + A FlowsList object. """ - if not flows_configs: + if not data: return cls([]) return cls( [ Flow.from_json(flow_id, flow_config) - for flow_id, flow_config in flows_configs.items() + for flow_id, flow_config in data.items() ] ) - def as_json(self) -> List[Dict[Text, Any]]: - """Returns the flows as a dictionary. + def as_json_list(self) -> List[Dict[Text, Any]]: + """Serialize the FlowsList object to list format and not to the original dict. Returns: - The flows as a dictionary. + The FlowsList object as serialized data in a list """ return [flow.as_json() for flow in self.underlying_flows] def fingerprint(self) -> str: - """Creates a fingerprint of the flows configuration. + """Creates a fingerprint of the existing flows. Returns: - The fingerprint of the flows configuration. + The fingerprint of the flows. """ flow_dicts = [flow.as_json() for flow in self.underlying_flows] return rasa.shared.utils.io.get_list_fingerprint(flow_dicts) @@ -81,13 +79,13 @@ def merge(self, other: FlowsList) -> FlowsList: """Merges two lists of flows together.""" return FlowsList(self.underlying_flows + other.underlying_flows) - def flow_by_id(self, id: Optional[Text]) -> Optional[Flow]: + def flow_by_id(self, flow_id: Optional[Text]) -> Optional[Flow]: """Return the flow with the given id.""" - if not id: + if not flow_id: return None for flow in self.underlying_flows: - if flow.id == id: + if flow.id == flow_id: return flow else: return None From cb8088999e272ba7f24e6859fe14d1a90b148f92 Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Thu, 26 Oct 2023 14:46:19 +0200 Subject: [PATCH 27/31] removed branch flow step and fixed predicate validation, added test --- rasa/core/policies/flow_policy.py | 9 ++--- rasa/shared/core/flows/flow_step.py | 7 ++-- rasa/shared/core/flows/steps/__init__.py | 2 - rasa/shared/core/flows/steps/action.py | 2 +- rasa/shared/core/flows/steps/branch.py | 40 ------------------- rasa/shared/core/flows/steps/collect.py | 2 +- .../core/flows/steps/generate_response.py | 2 +- rasa/shared/core/flows/steps/internal.py | 5 +++ rasa/shared/core/flows/steps/link.py | 2 +- rasa/shared/core/flows/steps/set_slots.py | 2 +- rasa/shared/core/flows/steps/user_message.py | 2 +- rasa/validator.py | 29 +++++++------- tests/test_validator.py | 22 ++++++++++ 13 files changed, 54 insertions(+), 72 deletions(-) delete mode 100644 rasa/shared/core/flows/steps/branch.py diff --git a/rasa/core/policies/flow_policy.py b/rasa/core/policies/flow_policy.py index 7ffe5f39e2d8..696be5cc70be 100644 --- a/rasa/core/policies/flow_policy.py +++ b/rasa/core/policies/flow_policy.py @@ -65,7 +65,6 @@ UserMessageStep, ) from rasa.shared.core.flows.steps.link import LinkFlowStep -from rasa.shared.core.flows.steps.branch import BranchFlowStep from rasa.shared.core.flows.steps.action import ActionFlowStep from rasa.shared.core.flows.flow import Flow from rasa.shared.core.flows.flows_list import FlowsList @@ -678,10 +677,6 @@ def run_step( structlogger.debug("flow.step.run.user_message") return ContinueFlowWithNextStep() - elif isinstance(step, BranchFlowStep): - structlogger.debug("flow.step.run.branch") - return ContinueFlowWithNextStep() - elif isinstance(step, GenerateResponseFlowStep): structlogger.debug("flow.step.run.generate_response") generated = step.generate(tracker) @@ -702,6 +697,10 @@ def run_step( reset_events = self._reset_scoped_slots(flow, tracker) return ContinueFlowWithNextStep(events=reset_events) + elif isinstance(step, FlowStep): + structlogger.debug("flow.step.run.base_flow_step") + return ContinueFlowWithNextStep() + else: raise FlowException(f"Unknown flow step type {type(step)}") diff --git a/rasa/shared/core/flows/flow_step.py b/rasa/shared/core/flows/flow_step.py index f8f5330d2411..7d6de83ab9aa 100644 --- a/rasa/shared/core/flows/flow_step.py +++ b/rasa/shared/core/flows/flow_step.py @@ -34,7 +34,6 @@ def step_from_json(data: Dict[Text, Any]) -> FlowStep: LinkFlowStep, SetSlotsFlowStep, GenerateResponseFlowStep, - BranchFlowStep, ) if "action" in data: @@ -50,7 +49,7 @@ def step_from_json(data: Dict[Text, Any]) -> FlowStep: if "generation_prompt" in data: return GenerateResponseFlowStep.from_json(data) else: - return BranchFlowStep.from_json(data) + return FlowStep.from_json(data) @dataclass @@ -69,7 +68,7 @@ class FlowStep: """The next steps of the flow step.""" @classmethod - def _from_json(cls, flow_step_config: Dict[Text, Any]) -> FlowStep: + def from_json(cls, flow_step_config: Dict[Text, Any]) -> FlowStep: """Used to read flow steps from parsed YAML. Args: @@ -122,7 +121,7 @@ def default_id(self) -> str: @property def default_id_postfix(self) -> str: """Returns the default id postfix of the flow step.""" - raise NotImplementedError() + return "step" @property def utterances(self) -> Set[str]: diff --git a/rasa/shared/core/flows/steps/__init__.py b/rasa/shared/core/flows/steps/__init__.py index e195b3d7b4cd..a33b2f3cd5bd 100644 --- a/rasa/shared/core/flows/steps/__init__.py +++ b/rasa/shared/core/flows/steps/__init__.py @@ -1,5 +1,4 @@ from .action import ActionFlowStep -from .branch import BranchFlowStep from .collect import CollectInformationFlowStep from .continuation import ContinueFlowStep from .end import EndFlowStep @@ -13,7 +12,6 @@ # to make ruff happy and use the imported names all_steps = [ ActionFlowStep, - BranchFlowStep, CollectInformationFlowStep, ContinueFlowStep, EndFlowStep, diff --git a/rasa/shared/core/flows/steps/action.py b/rasa/shared/core/flows/steps/action.py index 7e837fa6b169..fce3cf04d879 100644 --- a/rasa/shared/core/flows/steps/action.py +++ b/rasa/shared/core/flows/steps/action.py @@ -24,7 +24,7 @@ def from_json(cls, data: Dict[Text, Any]) -> ActionFlowStep: Returns: An ActionFlowStep object """ - base = super()._from_json(data) + base = super().from_json(data) return ActionFlowStep( action=data["action"], **base.__dict__, diff --git a/rasa/shared/core/flows/steps/branch.py b/rasa/shared/core/flows/steps/branch.py deleted file mode 100644 index 4220009361a9..000000000000 --- a/rasa/shared/core/flows/steps/branch.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Dict, Text, Any - -from rasa.shared.core.flows.flow_step import FlowStep - -# TODO: this is ambiguous, even steps that only have one static -# follow up might become branch flow steps -# validator also misuses it!!! -@dataclass -class BranchFlowStep(FlowStep): - """An unspecific FlowStep that has a next attribute.""" - - @classmethod - def from_json(cls, data: Dict[Text, Any]) -> BranchFlowStep: - """Create a BranchFlowStep object from serialized data. - - Args: - data: data for a BranchFlowStep object in a serialized format - - Returns: - A BranchFlowStep object. - """ - base = super()._from_json(data) - return BranchFlowStep(**base.__dict__) - - def as_json(self) -> Dict[Text, Any]: - """Serialize the BranchFlowStep object - - Returns: - the BranchFlowStep object as serialized data. - """ - dump = super().as_json() - return dump - - @property - def default_id_postfix(self) -> str: - """Returns the default id postfix of the flow step.""" - return "branch" diff --git a/rasa/shared/core/flows/steps/collect.py b/rasa/shared/core/flows/steps/collect.py index 1c197d85a45c..29bbff1e95a9 100644 --- a/rasa/shared/core/flows/steps/collect.py +++ b/rasa/shared/core/flows/steps/collect.py @@ -67,7 +67,7 @@ def from_json(cls, data: Dict[Text, Any]) -> CollectInformationFlowStep: Returns: A CollectInformationFlowStep object """ - base = super()._from_json(data) + base = super().from_json(data) return CollectInformationFlowStep( collect=data["collect"], utter=data.get("utter", f"utter_ask_{data['collect']}"), diff --git a/rasa/shared/core/flows/steps/generate_response.py b/rasa/shared/core/flows/steps/generate_response.py index d9720c7ad800..642b56b05a77 100644 --- a/rasa/shared/core/flows/steps/generate_response.py +++ b/rasa/shared/core/flows/steps/generate_response.py @@ -38,7 +38,7 @@ def from_json(cls, data: Dict[Text, Any]) -> GenerateResponseFlowStep: Returns: A GenerateResponseFlowStep object """ - base = super()._from_json(data) + base = super().from_json(data) return GenerateResponseFlowStep( generation_prompt=data["generation_prompt"], llm_config=data.get("llm"), diff --git a/rasa/shared/core/flows/steps/internal.py b/rasa/shared/core/flows/steps/internal.py index d0b5124d101b..c998085a0545 100644 --- a/rasa/shared/core/flows/steps/internal.py +++ b/rasa/shared/core/flows/steps/internal.py @@ -37,3 +37,8 @@ def as_json(self) -> Dict[Text, Any]: "Internal flow steps are ephemeral and are not to be serialized " "or de-serialized." ) + + @property + def default_id_postfix(self) -> str: + """Returns the default id postfix of the flow step.""" + raise ValueError("Internal flow steps do not need a default id") diff --git a/rasa/shared/core/flows/steps/link.py b/rasa/shared/core/flows/steps/link.py index 6f94e18f8acc..d9b164a83d4b 100644 --- a/rasa/shared/core/flows/steps/link.py +++ b/rasa/shared/core/flows/steps/link.py @@ -23,7 +23,7 @@ def from_json(cls, data: Dict[Text, Any]) -> LinkFlowStep: Returns: a LinkFlowStep object """ - base = super()._from_json(data) + base = super().from_json(data) return LinkFlowStep( link=data.get("link", ""), **base.__dict__, diff --git a/rasa/shared/core/flows/steps/set_slots.py b/rasa/shared/core/flows/steps/set_slots.py index b8fbdbfbc113..70b263fcecbd 100644 --- a/rasa/shared/core/flows/steps/set_slots.py +++ b/rasa/shared/core/flows/steps/set_slots.py @@ -23,7 +23,7 @@ def from_json(cls, data: Dict[Text, Any]) -> SetSlotsFlowStep: Returns: a SetSlotsFlowStep object """ - base = super()._from_json(data) + base = super().from_json(data) slots = [ {"key": k, "value": v} for slot_sets in data["set_slots"] diff --git a/rasa/shared/core/flows/steps/user_message.py b/rasa/shared/core/flows/steps/user_message.py index 6ab04afe91a6..317deccc2755 100644 --- a/rasa/shared/core/flows/steps/user_message.py +++ b/rasa/shared/core/flows/steps/user_message.py @@ -67,7 +67,7 @@ def from_json(cls, flow_step_config: Dict[Text, Any]) -> UserMessageStep: Returns: The parsed flow step. """ - base = super()._from_json(flow_step_config) + base = super().from_json(flow_step_config) trigger_conditions = [] if "intent" in flow_step_config: diff --git a/rasa/validator.py b/rasa/validator.py index d26809d6a775..7e55d7c89a82 100644 --- a/rasa/validator.py +++ b/rasa/validator.py @@ -10,7 +10,7 @@ from rasa.shared.core.flows.flow_step_links import IfFlowStepLink from rasa.shared.core.flows.steps.set_slots import SetSlotsFlowStep from rasa.shared.core.flows.steps.collect import CollectInformationFlowStep -from rasa.shared.core.flows.steps.branch import BranchFlowStep +from rasa.shared.core.flows.flow_step import FlowStep from rasa.shared.core.flows.steps.action import ActionFlowStep from rasa.shared.core.flows.flows_list import FlowsList import rasa.shared.nlu.constants @@ -631,24 +631,23 @@ def _construct_predicate( return pred, all_good def verify_predicates(self) -> bool: - """Checks that predicates used in branch flow steps or `collect` steps are valid.""" # noqa: E501 + """Validate predicates used in flow step links and slot rejections.""" all_good = True for flow in self.flows.underlying_flows: for step in flow.steps: - if isinstance(step, BranchFlowStep): - for link in step.next.links: - if isinstance(link, IfFlowStepLink): - predicate, all_good = Validator._construct_predicate( - link.condition, step.id + for link in step.next.links: + if isinstance(link, IfFlowStepLink): + predicate, all_good = Validator._construct_predicate( + link.condition, step.id + ) + if predicate and not predicate.is_valid(): + logger.error( + f"Detected invalid condition '{link.condition}' " + f"at step '{step.id}' for flow id '{flow.id}'. " + f"Please make sure that all conditions are valid." ) - if predicate and not predicate.is_valid(): - logger.error( - f"Detected invalid condition '{link.condition}' " - f"at step '{step.id}' for flow id '{flow.id}'. " - f"Please make sure that all conditions are valid." - ) - all_good = False - elif isinstance(step, CollectInformationFlowStep): + all_good = False + if isinstance(step, CollectInformationFlowStep): predicates = [predicate.if_ for predicate in step.rejections] for predicate in predicates: pred, all_good = Validator._construct_predicate( diff --git a/tests/test_validator.py b/tests/test_validator.py index 9d5207aa3cf6..18cbc34b72d6 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -6,6 +6,10 @@ import pytest from _pytest.logging import LogCaptureFixture from rasa.shared.constants import LATEST_TRAINING_DATA_FORMAT_VERSION +from rasa.shared.core.domain import Domain +from rasa.shared.core.flows.yaml_flows_io import flows_from_str +from rasa.shared.core.training_data.structures import StoryGraph +from rasa.shared.nlu.training_data.training_data import TrainingData from rasa.validator import Validator @@ -1349,6 +1353,24 @@ def test_verify_predicates_invalid_rejection_if( assert error_log in caplog.text +def test_flow_predicate_validation_fails_for_faulty_flow_link_predicates(): + flows = flows_from_str( + """ + flows: + pattern_bar: + steps: + - id: first + action: action_listen + next: + - if: xxx !!! + then: END + - else: END + """ + ) + validator = Validator(Domain.empty(), TrainingData(), StoryGraph([]), flows, None) + assert not validator.verify_predicates() + + @pytest.fixture def domain_file_name(tmp_path: Path) -> Path: domain_file_name = tmp_path / "domain.yml" From a6e99c17a4f357e9695d57cfb90a3868f7908e3a Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Thu, 26 Oct 2023 14:52:03 +0200 Subject: [PATCH 28/31] removed unused import --- rasa/validator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/rasa/validator.py b/rasa/validator.py index 7e55d7c89a82..0708da9b085c 100644 --- a/rasa/validator.py +++ b/rasa/validator.py @@ -10,7 +10,6 @@ from rasa.shared.core.flows.flow_step_links import IfFlowStepLink from rasa.shared.core.flows.steps.set_slots import SetSlotsFlowStep from rasa.shared.core.flows.steps.collect import CollectInformationFlowStep -from rasa.shared.core.flows.flow_step import FlowStep from rasa.shared.core.flows.steps.action import ActionFlowStep from rasa.shared.core.flows.flows_list import FlowsList import rasa.shared.nlu.constants From a602a9a7ed54012651e98d5dca0fbf5cc57cb183 Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Thu, 26 Oct 2023 15:08:58 +0200 Subject: [PATCH 29/31] excluded conditions with jinja templating from tests for now --- rasa/validator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rasa/validator.py b/rasa/validator.py index 0708da9b085c..316328495789 100644 --- a/rasa/validator.py +++ b/rasa/validator.py @@ -636,6 +636,9 @@ def verify_predicates(self) -> bool: for step in flow.steps: for link in step.next.links: if isinstance(link, IfFlowStepLink): + # TODO: need to handle link conditions with context / jinja + if "{{" in link.condition: + continue predicate, all_good = Validator._construct_predicate( link.condition, step.id ) From 8c0260b7e393ddbb30e7535f706fe1ac31a49a12 Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Fri, 27 Oct 2023 12:50:40 +0200 Subject: [PATCH 30/31] moved type check for former branch flow step back where it was and adjusted check --- rasa/core/policies/flow_policy.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/rasa/core/policies/flow_policy.py b/rasa/core/policies/flow_policy.py index 696be5cc70be..2682b251561b 100644 --- a/rasa/core/policies/flow_policy.py +++ b/rasa/core/policies/flow_policy.py @@ -677,6 +677,10 @@ def run_step( structlogger.debug("flow.step.run.user_message") return ContinueFlowWithNextStep() + elif type(step) is FlowStep: + structlogger.debug("flow.step.run.base_flow_step") + return ContinueFlowWithNextStep() + elif isinstance(step, GenerateResponseFlowStep): structlogger.debug("flow.step.run.generate_response") generated = step.generate(tracker) @@ -697,10 +701,6 @@ def run_step( reset_events = self._reset_scoped_slots(flow, tracker) return ContinueFlowWithNextStep(events=reset_events) - elif isinstance(step, FlowStep): - structlogger.debug("flow.step.run.base_flow_step") - return ContinueFlowWithNextStep() - else: raise FlowException(f"Unknown flow step type {type(step)}") From 48c0718f5d3f522734cddc737ea58f5cda8f3f71 Mon Sep 17 00:00:00 2001 From: Thomas Werkmeister Date: Fri, 27 Oct 2023 15:55:28 +0200 Subject: [PATCH 31/31] Added tests for flows io, fixed bug that made serialization and deserialization unequal --- data/test_flows/basic_flows.yml | 10 +++++ rasa/shared/core/flows/flow_step.py | 5 ++- rasa/shared/core/flows/flows_list.py | 21 ++++------ rasa/shared/importers/importer.py | 4 +- rasa/shared/importers/utils.py | 2 +- tests/conftest.py | 14 +++++++ tests/core/flows/test_flow.py | 2 +- tests/core/flows/test_flows_io.py | 41 +++++++++++++++++++ .../commands/test_set_slot_command.py | 2 +- .../stack/frames/test_flow_frame.py | 10 ++--- 10 files changed, 85 insertions(+), 26 deletions(-) create mode 100644 data/test_flows/basic_flows.yml create mode 100644 tests/core/flows/test_flows_io.py diff --git a/data/test_flows/basic_flows.yml b/data/test_flows/basic_flows.yml new file mode 100644 index 000000000000..26e39f7c56ac --- /dev/null +++ b/data/test_flows/basic_flows.yml @@ -0,0 +1,10 @@ +flows: + foo: + description: "A test flow" + steps: + - action: "utter_test" + bar: + description: "Another test flow" + steps: + - action: "utter_greet" + - collect: "important_info" diff --git a/rasa/shared/core/flows/flow_step.py b/rasa/shared/core/flows/flow_step.py index 7d6de83ab9aa..7d8acb70eb8a 100644 --- a/rasa/shared/core/flows/flow_step.py +++ b/rasa/shared/core/flows/flow_step.py @@ -95,8 +95,9 @@ def as_json(self) -> Dict[Text, Any]: Returns: The FlowStep as serialized data. """ - data: Dict[Text, Any] = {"next": self.next.as_json(), "id": self.id} - + data: Dict[Text, Any] = {"next": self.next.as_json()} + if self.custom_id: + data["id"] = self.custom_id if self.description: data["description"] = self.description if self.metadata: diff --git a/rasa/shared/core/flows/flows_list.py b/rasa/shared/core/flows/flows_list.py index 841cbdfb1ba8..05ea16397aed 100644 --- a/rasa/shared/core/flows/flows_list.py +++ b/rasa/shared/core/flows/flows_list.py @@ -1,5 +1,5 @@ from __future__ import annotations - +from dataclasses import dataclass from typing import List, Generator, Any, Optional, Dict, Text, Set import rasa.shared.utils.io @@ -7,6 +7,7 @@ from rasa.shared.core.flows.validation import validate_flow +@dataclass class FlowsList: """A collection of flows. @@ -15,24 +16,16 @@ class FlowsList: specific attributes or collecting all utterances across all flows. """ - def __init__(self, flows: List[Flow]) -> None: - """Initializes the FlowsList object. - - Args: - flows: The flows for this collection. - """ - self.underlying_flows = flows + underlying_flows: List[Flow] + """The flows contained in this FlowsList.""" def __iter__(self) -> Generator[Flow, None, None]: """Iterates over the flows.""" yield from self.underlying_flows - def __eq__(self, other: Any) -> bool: - """Compares this FlowsList to another one.""" - return ( - isinstance(other, FlowsList) - and self.underlying_flows == other.underlying_flows - ) + def __len__(self): + """Return the length of this FlowsList.""" + return len(self.underlying_flows) def is_empty(self) -> bool: """Returns whether the flows list is empty.""" diff --git a/rasa/shared/importers/importer.py b/rasa/shared/importers/importer.py index eab949c5fe87..eae10ee57a0f 100644 --- a/rasa/shared/importers/importer.py +++ b/rasa/shared/importers/importer.py @@ -71,7 +71,7 @@ def get_flows(self) -> FlowsList: Returns: `FlowsList` containing all loaded flows. """ - return FlowsList(flows=[]) + return FlowsList([]) def get_conversation_tests(self) -> StoryGraph: """Retrieves end-to-end conversation stories for testing. @@ -310,7 +310,7 @@ def get_flows(self) -> FlowsList: flow_lists = [importer.get_flows() for importer in self._importers] return reduce( - lambda merged, other: merged.merge(other), flow_lists, FlowsList(flows=[]) + lambda merged, other: merged.merge(other), flow_lists, FlowsList([]) ) @rasa.shared.utils.common.cached_method diff --git a/rasa/shared/importers/utils.py b/rasa/shared/importers/utils.py index 5a2ade83568d..775035081b09 100644 --- a/rasa/shared/importers/utils.py +++ b/rasa/shared/importers/utils.py @@ -27,7 +27,7 @@ def flows_from_paths(files: List[Text]) -> FlowsList: """Returns the flows from paths.""" from rasa.shared.core.flows.yaml_flows_io import YAMLFlowsReader - flows = FlowsList(flows=[]) + flows = FlowsList([]) for file in files: flows = flows.merge(YAMLFlowsReader.read_from_file(file)) return flows diff --git a/tests/conftest.py b/tests/conftest.py index cb09a9ecb3dd..3c97dbb47875 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -930,3 +930,17 @@ def tracker_with_restarted_event( events = initial_events_including_restart + events_after_restart return DialogueStateTracker.from_events(sender_id=sender_id, evts=events) + + +@pytest.fixture(scope="session") +def tests_folder() -> str: + tests_folder = os.path.dirname(os.path.abspath(__file__)) + assert os.path.isdir(tests_folder) + return tests_folder + + +@pytest.fixture(scope="session") +def tests_data_folder(tests_folder: str) -> str: + tests_data_folder = os.path.join(os.path.split(tests_folder)[0], "data") + assert os.path.isdir(tests_data_folder) + return tests_data_folder diff --git a/tests/core/flows/test_flow.py b/tests/core/flows/test_flow.py index b293bdb791a4..6249bdeee0e8 100644 --- a/tests/core/flows/test_flow.py +++ b/tests/core/flows/test_flow.py @@ -43,7 +43,7 @@ def only_patterns() -> FlowsList: @pytest.fixture def empty_flowlist() -> FlowsList: - return FlowsList(flows=[]) + return FlowsList([]) def test_user_flow_ids(user_flows_and_patterns: FlowsList): diff --git a/tests/core/flows/test_flows_io.py b/tests/core/flows/test_flows_io.py new file mode 100644 index 000000000000..b58f816f1018 --- /dev/null +++ b/tests/core/flows/test_flows_io.py @@ -0,0 +1,41 @@ +import os +import pytest +import tempfile +from rasa.shared.core.flows.yaml_flows_io import ( + is_flows_file, + YAMLFlowsReader, + YamlFlowsWriter, +) + + +@pytest.fixture(scope="module") +def basic_flows_file(tests_data_folder: str) -> str: + return os.path.join(tests_data_folder, "test_flows", "basic_flows.yml") + + +@pytest.mark.parametrize( + "path, expected_result", + [ + (os.path.join("test_flows", "basic_flows.yml"), True), + (os.path.join("test_moodbot", "domain.yml"), False), + ], +) +def test_is_flows_file(tests_data_folder: str, path: str, expected_result: bool): + full_path = os.path.join(tests_data_folder, path) + assert is_flows_file(full_path) == expected_result + + +def test_flow_reading(basic_flows_file: str): + flows_list = YAMLFlowsReader.read_from_file(basic_flows_file) + assert len(flows_list) == 2 + assert flows_list.flow_by_id("foo") is not None + assert flows_list.flow_by_id("bar") is not None + + +def test_flow_writing(basic_flows_file: str): + flows_list = YAMLFlowsReader.read_from_file(basic_flows_file) + tmp_file_descriptor, tmp_file_name = tempfile.mkstemp() + YamlFlowsWriter.dump(flows_list.underlying_flows, tmp_file_name) + + re_read_flows_list = YAMLFlowsReader.read_from_file(tmp_file_name) + assert re_read_flows_list == flows_list diff --git a/tests/dialogue_understanding/commands/test_set_slot_command.py b/tests/dialogue_understanding/commands/test_set_slot_command.py index 597b510b9ce5..1f62846c4d3a 100644 --- a/tests/dialogue_understanding/commands/test_set_slot_command.py +++ b/tests/dialogue_understanding/commands/test_set_slot_command.py @@ -38,7 +38,7 @@ def test_run_command_skips_if_slot_is_set_to_same_value(): tracker = DialogueStateTracker.from_events("test", evts=[SlotSet("foo", "bar")]) command = SetSlotCommand(name="foo", value="bar") - assert command.run_command_on_tracker(tracker, FlowsList(flows=[]), tracker) == [] + assert command.run_command_on_tracker(tracker, FlowsList([]), tracker) == [] def test_run_command_sets_slot_if_asked_for(): diff --git a/tests/dialogue_understanding/stack/frames/test_flow_frame.py b/tests/dialogue_understanding/stack/frames/test_flow_frame.py index 2825c3cca6f7..8bfd4ff14533 100644 --- a/tests/dialogue_understanding/stack/frames/test_flow_frame.py +++ b/tests/dialogue_understanding/stack/frames/test_flow_frame.py @@ -59,14 +59,14 @@ def test_flow_get_flow(): name="foo flow", description="foo flow description", ) - all_flows = FlowsList(flows=[flow]) + all_flows = FlowsList([flow]) assert frame.flow(all_flows) == flow def test_flow_get_flow_non_existant_id(): frame = UserFlowStackFrame(frame_id="test", flow_id="unknown", step_id="bar") all_flows = FlowsList( - flows=[ + [ Flow( id="foo", step_sequence=FlowStepSequence(child_steps=[]), @@ -90,7 +90,7 @@ def test_flow_get_step(): next=FlowStepLinks(links=[]), ) all_flows = FlowsList( - flows=[ + [ Flow( id="foo", step_sequence=FlowStepSequence(child_steps=[step]), @@ -105,7 +105,7 @@ def test_flow_get_step(): def test_flow_get_step_non_existant_id(): frame = UserFlowStackFrame(frame_id="test", flow_id="foo", step_id="unknown") all_flows = FlowsList( - flows=[ + [ Flow( id="foo", step_sequence=FlowStepSequence(child_steps=[]), @@ -121,7 +121,7 @@ def test_flow_get_step_non_existant_id(): def test_flow_get_step_non_existant_flow_id(): frame = UserFlowStackFrame(frame_id="test", flow_id="unknown", step_id="unknown") all_flows = FlowsList( - flows=[ + [ Flow( id="foo", step_sequence=FlowStepSequence(child_steps=[]),