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/core/actions/action_clean_stack.py b/rasa/core/actions/action_clean_stack.py index a885abbdbf8e..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 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 fcd282e2233e..2682b251561b 100644 --- a/rasa/core/policies/flow_policy.py +++ b/rasa/core/policies/flow_policy.py @@ -44,26 +44,31 @@ ACTION_SEND_TEXT_NAME, ) from rasa.shared.core.events import Event, SlotSet -from rasa.shared.core.flows.flow import ( - END_STEP, - ActionFlowStep, - BranchFlowStep, - ContinueFlowStep, - ElseFlowLink, - EndFlowStep, - Flow, +from rasa.shared.core.flows.flow_step import ( FlowStep, - FlowsList, - GenerateResponseFlowStep, - IfFlowLink, +) +from rasa.shared.core.flows.flow_step_links import ( + IfFlowStepLink, + ElseFlowStepLink, + StaticFlowStepLink, +) +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.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.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 @@ -364,18 +369,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: @@ -672,8 +677,8 @@ def run_step( structlogger.debug("flow.step.run.user_message") return ContinueFlowWithNextStep() - elif isinstance(step, BranchFlowStep): - structlogger.debug("flow.step.run.branch") + elif type(step) is FlowStep: + structlogger.debug("flow.step.run.base_flow_step") return ContinueFlowWithNextStep() elif isinstance(step, GenerateResponseFlowStep): 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..f7b121bd11f7 100644 --- a/rasa/dialogue_understanding/commands/correct_slots_command.py +++ b/rasa/dialogue_understanding/commands/correct_slots_command.py @@ -16,7 +16,10 @@ 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_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/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..81195adf5a40 100644 --- a/rasa/dialogue_understanding/generator/llm_command_generator.py +++ b/rasa/dialogue_understanding/generator/llm_command_generator.py @@ -23,12 +23,12 @@ 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, - FlowsList, - 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 from rasa.shared.core.slots import ( BooleanSlot, diff --git a/rasa/dialogue_understanding/patterns/cancel.py b/rasa/dialogue_understanding/patterns/cancel.py index b60df2e004cc..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 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 9aa35824e888..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 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 1409bba8fba1..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 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 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 e80a9c0998b2..fa8346e26f00 100644 --- a/rasa/dialogue_understanding/processor/command_processor.py +++ b/rasa/dialogue_understanding/processor/command_processor.py @@ -28,10 +28,8 @@ ) 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.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/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..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,10 @@ 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_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 71e59b90d4ba..80fc90459689 100644 --- a/rasa/dialogue_understanding/stack/utils.py +++ b/rasa/dialogue_understanding/stack/utils.py @@ -5,7 +5,9 @@ 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.steps.constants import END_STEP +from rasa.shared.core.flows.steps.continuation import 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 647249c69c50..e19384860e07 100644 --- a/rasa/graph_components/providers/flows_provider.py +++ b/rasa/graph_components/providers/flows_provider.py @@ -8,9 +8,9 @@ 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_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: 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/flow.py b/rasa/shared/core/flows/flow.py index 656309c23564..fd806ad55477 100644 --- a/rasa/shared/core/flows/flow.py +++ b/rasa/shared/core/flows/flow.py @@ -2,291 +2,25 @@ from dataclasses import dataclass from functools import cached_property -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 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 +from typing import Text, Optional, Dict, Any, List, Set import rasa.shared.utils.io -from rasa.shared.utils.llm import ( - DEFAULT_OPENAI_GENERATE_MODEL_NAME, - DEFAULT_OPENAI_TEMPERATURE, +from rasa.shared.constants import RASA_DEFAULT_FLOW_PATTERN_PREFIX +from rasa.shared.core.flows.flow_step import ( + FlowStep, ) - -structlogger = structlog.get_logger() - -START_STEP = "START" - -END_STEP = "END" - -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. - - 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 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, referenced_from=None) - - return step - - 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]) +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, + 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 FlowStepSequence @dataclass @@ -299,25 +33,25 @@ 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 - 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 = FlowStepSequence.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), ) @@ -327,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 @@ -355,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(target=END_STEP)) + step.next.links.append(StaticFlowStepLink(END_STEP)) else: - step.next.links.append(StaticFlowLink(target=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) @@ -366,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, @@ -382,79 +116,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_all_steps_next_property() - self._validate_all_next_ids_are_availble_steps() - self._validate_all_steps_can_be_reached() - self._validate_all_branches_have_an_else() - self._validate_not_using_buildin_ids() - - 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) - - 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, self) - - 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, self) - elif step.next.no_link_available(): - # all other steps should have a next link - raise MissingNextLinkException(step, self) - - def _validate_all_next_ids_are_availble_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) - - 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, self) - def step_by_id(self, step_id: Optional[Text]) -> Optional[FlowStep]: """Returns the step with the given id.""" if not step_id: @@ -485,17 +146,17 @@ 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. + """Return the CollectInformationFlowSteps asked before the given step. - CollectInformations 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( 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. """ @@ -524,33 +185,13 @@ 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.""" + """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): @@ -559,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 @@ -571,969 +212,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(target=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(target=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: 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=[ - FlowLinks.link_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) -> 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(Protocol): - """Represents a flow link.""" - - @property - def target(self) -> Optional[Text]: - """Returns the target of the flow link. - - Returns: - The target of the flow link. - """ - ... - - def as_json(self) -> Any: - """Returns the flow link as a dictionary. - - Returns: - The flow link as a dictionary. - """ - ... - - @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. - """ - ... - - def steps_in_tree(self) -> Generator[FlowStep, None, None]: - """Returns the steps in the tree of the flow link.""" - ... - - def child_steps(self) -> List[FlowStep]: - """Returns the child steps of the flow link.""" - ... - - -@dataclass -class BranchBasedLink: - 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 - - -@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: - """Represents the configuration of a static flow link.""" - - target: 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(target=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 [] diff --git a/rasa/shared/core/flows/flow_step.py b/rasa/shared/core/flows/flow_step.py new file mode 100644 index 000000000000..7d8acb70eb8a --- /dev/null +++ b/rasa/shared/core/flows/flow_step.py @@ -0,0 +1,130 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +from dataclasses import dataclass +from typing import ( + Any, + Dict, + Generator, + Optional, + Set, + Text, +) +import structlog + +if TYPE_CHECKING: + from rasa.shared.core.flows.flow_step_links import FlowStepLinks + +structlogger = structlog.get_logger() + + +def step_from_json(data: Dict[Text, Any]) -> FlowStep: + """Create a specific FlowStep from serialized data. + + Args: + data: data for a specific FlowStep object in a serialized data format. + + Returns: + An instance of a specific FlowStep class. + """ + from rasa.shared.core.flows.steps import ( + ActionFlowStep, + UserMessageStep, + CollectInformationFlowStep, + LinkFlowStep, + SetSlotsFlowStep, + GenerateResponseFlowStep, + ) + + 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 FlowStep.from_json(data) + + +@dataclass +class FlowStep: + """A single step in a flow.""" + + 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: FlowStepLinks + """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. + """ + from rasa.shared.core.flows.flow_step_links import FlowStepLinks + + 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=FlowStepLinks.from_json(flow_step_config.get("next", [])), + ) + + def as_json(self) -> Dict[Text, Any]: + """Serialize the FlowStep object. + + Returns: + The FlowStep as serialized data. + """ + 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: + data["metadata"] = self.metadata + return data + + def steps_in_tree(self) -> Generator[FlowStep, None, None]: + """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 + + @property + def default_id(self) -> str: + """Returns the default id of the flow step.""" + return f"{self.idx}_{self.default_id_postfix}" + + @property + def default_id_postfix(self) -> str: + """Returns the default id postfix of the flow step.""" + return "step" + + @property + def utterances(self) -> Set[str]: + """Return all the utterances used in this step""" + return set() 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..915333c2ca73 --- /dev/null +++ b/rasa/shared/core/flows/flow_step_links.py @@ -0,0 +1,281 @@ +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 FlowStepSequence + + +@dataclass +class FlowStepLinks: + """A list of flow step links.""" + + links: List[FlowStepLink] + + @staticmethod + def from_json(data: Union[str, List[Dict[Text, Any]]]) -> FlowStepLinks: + """Create a FlowStepLinks object from a serialized data format. + + Args: + data: data for a FlowStepLinks object in a serialized format. + + Returns: + A FlowStepLinks object. + """ + if not data: + return FlowStepLinks(links=[]) + + if isinstance(data, str): + return FlowStepLinks(links=[StaticFlowStepLink.from_json(data)]) + + return FlowStepLinks( + links=[ + BranchingFlowStepLink.from_json(link_config) + for link_config in data + if link_config + ] + ) + + def as_json(self) -> Optional[Union[str, List[Dict[str, Any]]]]: + """Serialize the FlowStepLinks object. + + Returns: + The FlowStepLinks object as serialized data. + """ + if not self.links: + return None + + 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] + + 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 step links.""" + for link in self.links: + yield from link.steps_in_tree() + + +class FlowStepLink: + """A flow step link that links two steps in a single flow.""" + + @property + def target(self) -> Text: + """Returns the target flow step id. + + Returns: + The target flow step id. + """ + raise NotImplementedError() + + def as_json(self) -> Any: + """Serialize the FlowStepLink object. + + Returns: + The FlowStepLink as serialized data. + """ + raise NotImplementedError() + + @staticmethod + def from_json(data: Any) -> FlowStepLink: + """Create a FlowStepLink object from a serialized data format. + + Args: + data: data for a FlowStepLink object in a serialized format. + + Returns: + The FlowStepLink object. + """ + raise NotImplementedError() + + def steps_in_tree(self) -> Generator[FlowStep, None, None]: + """Recursively generates the steps in the tree.""" + raise NotImplementedError() + + def child_steps(self) -> List[FlowStep]: + """Returns the steps of the linked FlowStepSequence if any.""" + raise NotImplementedError() + + +@dataclass +class BranchingFlowStepLink(FlowStepLink): + 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 FlowStepSequence + + 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 FlowStepSequence + + if isinstance(self.target_reference, FlowStepSequence): + return self.target_reference.child_steps + else: + return [] + + @property + def target(self) -> Text: + """Return the target flow step id.""" + from rasa.shared.core.flows.flow_step_sequence import FlowStepSequence + + if isinstance(self.target_reference, FlowStepSequence): + if first := self.target_reference.first(): + return first.id + else: + raise RuntimeError( + "Step sequence is empty despite previous validation of " + "this not happening" + ) + else: + return self.target_reference + + @staticmethod + def from_json(data: Dict[Text, Any]) -> BranchingFlowStepLink: + """Create a BranchingFlowStepLink object from a serialized data format. + + Args: + data: data for a BranchingFlowStepLink object in a serialized format. + + Returns: + a BranchingFlowStepLink object. + """ + if "if" in data: + return IfFlowStepLink.from_json(data) + else: + return ElseFlowStepLink.from_json(data) + + +@dataclass +class IfFlowStepLink(BranchingFlowStepLink): + """A flow step link that links to another step or step sequence conditionally.""" + + condition: Text + """The condition that needs to be satisfied to follow this flow step link.""" + + @staticmethod + def from_json(data: Dict[Text, Any]) -> IfFlowStepLink: + """Create an IfFlowStepLink object from a serialized data format. + + Args: + data: data for a IfFlowStepLink in a serialized format. + + Returns: + An IfFlowStepLink object. + """ + 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=FlowStepSequence.from_json(data["then"]), + condition=data["if"], + ) + + def as_json(self) -> Dict[Text, Any]: + """Serialize the IfFlowStepLink object. + + Returns: + the IfFlowStepLink object as serialized data. + """ + 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, FlowStepSequence) + else self.target_reference, + } + + +@dataclass +class ElseFlowStepLink(BranchingFlowStepLink): + """A flow step link that is taken when conditional flow step links weren't taken.""" + + @staticmethod + def from_json(data: Dict[Text, Any]) -> ElseFlowStepLink: + """Create an ElseFlowStepLink object from serialized data. + + Args: + data: data for an ElseFlowStepLink in a serialized format + + Returns: + An ElseFlowStepLink + """ + 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=FlowStepSequence.from_json(data["else"]) + ) + + def as_json(self) -> Dict[Text, Any]: + """Serialize the ElseFlowStepLink object + + Returns: + The ElseFlowStepLink as serialized data. + """ + from rasa.shared.core.flows.flow_step_sequence import FlowStepSequence + + return { + "else": self.target_reference.as_json() + if isinstance(self.target_reference, FlowStepSequence) + else self.target_reference + } + + +@dataclass +class StaticFlowStepLink(FlowStepLink): + """A static flow step link, linking to a step in the same flow unconditionally.""" + + target_step_id: Text + """The id of the linked step.""" + + @staticmethod + def from_json(data: Text) -> StaticFlowStepLink: + """Create a StaticFlowStepLink from serialized data + + Args: + data: data for a StaticFlowStepLink in a serialized format + + Returns: + A StaticFlowStepLink object + """ + return StaticFlowStepLink(data) + + def as_json(self) -> Text: + """Serialize the StaticFlowStepLink object + + Returns: + The StaticFlowStepLink object as serialized data. + """ + return self.target + + def steps_in_tree(self) -> Generator[FlowStep, None, None]: + """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 steps of the linked FlowStepSequence if any.""" + return [] + + @property + def target(self) -> Text: + """Returns the target step id.""" + return self.target_step_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..e08d859042dd --- /dev/null +++ b/rasa/shared/core/flows/flow_step_sequence.py @@ -0,0 +1,56 @@ +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 FlowStepSequence: + """A Sequence of flow steps.""" + + child_steps: List[FlowStep] + + @staticmethod + def from_json(data: List[Dict[Text, Any]]) -> FlowStepSequence: + """Create a StepSequence object from serialized data + + Args: + data: data for a StepSequence in a serialized format + + Returns: + A StepSequence object including its flow step objects. + """ + + flow_steps: List[FlowStep] = [step_from_json(config) for config in data] + + return FlowStepSequence(child_steps=flow_steps) + + def as_json(self) -> List[Dict[Text, Any]]: + """Serialize the StepSequence object and contained FlowStep objects + + Returns: + the StepSequence and its FlowSteps as serialized data + """ + return [ + step.as_json() + for step in self.child_steps + if not isinstance(step, InternalFlowStep) + ] + + @property + def steps(self) -> List[FlowStep]: + """Return all steps in this step sequence and their sub steps.""" + return [ + step + for child_step in self.child_steps + for step in child_step.steps_in_tree() + ] + + def first(self) -> Optional[FlowStep]: + """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/flows_list.py b/rasa/shared/core/flows/flows_list.py new file mode 100644 index 000000000000..05ea16397aed --- /dev/null +++ b/rasa/shared/core/flows/flows_list.py @@ -0,0 +1,112 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import List, Generator, Any, Optional, Dict, Text, Set + +import rasa.shared.utils.io +from rasa.shared.core.flows.flow import Flow +from rasa.shared.core.flows.validation import validate_flow + + +@dataclass +class FlowsList: + """A collection of flows. + + 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. + """ + + 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 __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.""" + return len(self.underlying_flows) == 0 + + @classmethod + def from_json(cls, data: Optional[Dict[Text, Dict[Text, Any]]]) -> FlowsList: + """Create a FlowsList object from serialized data + + Args: + data: data for a FlowsList in a serialized format + + Returns: + A FlowsList object. + """ + if not data: + return cls([]) + + return cls( + [ + Flow.from_json(flow_id, flow_config) + for flow_id, flow_config in data.items() + ] + ) + + def as_json_list(self) -> List[Dict[Text, Any]]: + """Serialize the FlowsList object to list format and not to the original dict. + + Returns: + 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 existing flows. + + Returns: + 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) + + 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, flow_id: Optional[Text]) -> Optional[Flow]: + """Return the flow with the given id.""" + if not flow_id: + return None + + for flow in self.underlying_flows: + if flow.id == flow_id: + return flow + else: + return None + + def validate(self) -> None: + """Validate the flows.""" + for flow in self.underlying_flows: + validate_flow(flow) + + @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/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": [ diff --git a/rasa/shared/core/flows/steps/__init__.py b/rasa/shared/core/flows/steps/__init__.py new file mode 100644 index 000000000000..a33b2f3cd5bd --- /dev/null +++ b/rasa/shared/core/flows/steps/__init__.py @@ -0,0 +1,24 @@ +from .action import ActionFlowStep +from .collect import CollectInformationFlowStep +from .continuation import ContinueFlowStep +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 + +# to make ruff happy and use the imported names +all_steps = [ + ActionFlowStep, + CollectInformationFlowStep, + ContinueFlowStep, + EndFlowStep, + GenerateResponseFlowStep, + InternalFlowStep, + LinkFlowStep, + SetSlotsFlowStep, + StartFlowStep, + 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..fce3cf04d879 --- /dev/null +++ b/rasa/shared/core/flows/steps/action.py @@ -0,0 +1,50 @@ +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): + """A flow step that that defines an action to be executed.""" + + action: Text + """The action of the flow step.""" + + @classmethod + def from_json(cls, data: Dict[Text, Any]) -> ActionFlowStep: + """Create an ActionFlowStep object from serialized data + + Args: + data: data for an ActionFlowStep object in a serialized format + + Returns: + An ActionFlowStep object + """ + base = super().from_json(data) + return ActionFlowStep( + action=data["action"], + **base.__dict__, + ) + + def as_json(self) -> Dict[Text, Any]: + """Serialize the ActionFlowStep + + Returns: + The ActionFlowStep object as serialized data. + """ + data = super().as_json() + data["action"] = self.action + return data + + @property + 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/collect.py b/rasa/shared/core/flows/steps/collect.py new file mode 100644 index 000000000000..29bbff1e95a9 --- /dev/null +++ b/rasa/shared/core/flows/steps/collect.py @@ -0,0 +1,106 @@ +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 pair of validation condition and an utterance for the case of failure.""" + + 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(data: Dict[Text, Any]) -> SlotRejection: + """Create a SlotRejection object from serialized data + + Args: + data: data for a SlotRejection object in a serialized format + + Returns: + A SlotRejection object + """ + return SlotRejection( + if_=data["if"], + utter=data["utter"], + ) + + def as_dict(self) -> Dict[Text, Any]: + """Serialize the SlotRejection object + + Returns: + the SlotRejection object as serialized data + """ + return { + "if": self.if_, + "utter": self.utter, + } + + +@dataclass +class CollectInformationFlowStep(FlowStep): + """A flow step for asking the user for information to fill a specific slot.""" + + 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, data: Dict[Text, Any]) -> CollectInformationFlowStep: + """Create a CollectInformationFlowStep object from serialized data + + Args: + data: data for a CollectInformationFlowStep object in a serialized format + + Returns: + A CollectInformationFlowStep object + """ + base = super().from_json(data) + return CollectInformationFlowStep( + 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 data.get("rejections", []) + ], + **base.__dict__, + ) + + def as_json(self) -> Dict[Text, Any]: + """Serialize the CollectInformationFlowStep object. + + Returns: + the CollectInformationFlowStep object as serialized data + """ + 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 data + + @property + 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..84711e35404e --- /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 FlowStepLinks, StaticFlowStepLink +from rasa.shared.core.flows.steps.constants import CONTINUE_STEP_PREFIX +from rasa.shared.core.flows.steps.internal import InternalFlowStep + + +@dataclass +class ContinueFlowStep(InternalFlowStep): + """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.""" + 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=FlowStepLinks(links=[StaticFlowStepLink(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..4e615f5d8487 --- /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 FlowStepLinks +from rasa.shared.core.flows.steps.constants import END_STEP +from rasa.shared.core.flows.steps.internal import InternalFlowStep + + +@dataclass +class EndFlowStep(InternalFlowStep): + """A dynamically added flow step that marks the end of a flow.""" + + def __init__(self) -> None: + """Initializes an end flow step.""" + super().__init__( + idx=0, + custom_id=END_STEP, + description=None, + metadata={}, + next=FlowStepLinks(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..642b56b05a77 --- /dev/null +++ b/rasa/shared/core/flows/steps/generate_response.py @@ -0,0 +1,96 @@ +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): + """A flow step that creates a free-form bot utterance using 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, data: Dict[Text, Any]) -> GenerateResponseFlowStep: + """Create a GenerateResponseFlowStep from serialized data + + Args: + data: data for a GenerateResponseFlowStep in a serialized format + + Returns: + A GenerateResponseFlowStep object + """ + base = super().from_json(data) + return GenerateResponseFlowStep( + generation_prompt=data["generation_prompt"], + llm_config=data.get("llm"), + **base.__dict__, + ) + + def as_json(self) -> Dict[Text, Any]: + """Serialize the GenerateResponseFlowStep object. + + Returns: + the GenerateResponseFlowStep object as serialized data. + """ + data = super().as_json() + data["generation_prompt"] = self.generation_prompt + if self.llm_config: + data["llm"] = self.llm_config + + return data + + 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 + + @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 new file mode 100644 index 000000000000..c998085a0545 --- /dev/null +++ b/rasa/shared/core/flows/steps/internal.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from typing import Dict, Text, Any + +from rasa.shared.core.flows.flow_step import FlowStep + + +class InternalFlowStep(FlowStep): + """A superclass for built-in flow steps. + + 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, data: Dict[Text, Any]) -> InternalFlowStep: + """Create an InternalFlowStep object from serialized data. + + Args: + data: data for an InternalFlowStep in a serialized format + + Returns: + Raises because InternalFlowSteps are not serialized or de-serialized. + """ + raise ValueError( + "Internal flow steps are ephemeral and are not to be serialized " + "or de-serialized." + ) + + def as_json(self) -> Dict[Text, Any]: + """Serialize the InternalFlowStep object + + Returns: + Raises because InternalFlowSteps are not serialized or de-serialized. + """ + raise ValueError( + "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 new file mode 100644 index 000000000000..d9b164a83d4b --- /dev/null +++ b/rasa/shared/core/flows/steps/link.py @@ -0,0 +1,45 @@ +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): + """A flow step at the end of a flow that links to and starts another flow.""" + + link: Text + """The id of the flow that should be started subsequently.""" + + @classmethod + def from_json(cls, data: Dict[Text, Any]) -> LinkFlowStep: + """Create a LinkFlowStep from serialized data + + Args: + data: data for a LinkFlowStep in a serialized format + + Returns: + a LinkFlowStep object + """ + base = super().from_json(data) + return LinkFlowStep( + link=data.get("link", ""), + **base.__dict__, + ) + + def as_json(self) -> Dict[Text, Any]: + """Serialize the LinkFlowStep object + + Returns: + the LinkFlowStep object as serialized data. + """ + 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 new file mode 100644 index 000000000000..70b263fcecbd --- /dev/null +++ b/rasa/shared/core/flows/steps/set_slots.py @@ -0,0 +1,50 @@ +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): + """A flow step that sets one or multiple slots.""" + + slots: List[Dict[str, Any]] + """Slots and their values to set in the flow step.""" + + @classmethod + def from_json(cls, data: Dict[Text, Any]) -> SetSlotsFlowStep: + """Create a SetSlotsFlowStep from serialized data + + Args: + data: data for a SetSlotsFlowStep in a serialized format + + Returns: + a SetSlotsFlowStep object + """ + base = super().from_json(data) + slots = [ + {"key": k, "value": v} + for slot_sets in data["set_slots"] + for k, v in slot_sets.items() + ] + return SetSlotsFlowStep( + slots=slots, + **base.__dict__, + ) + + def as_json(self) -> Dict[Text, Any]: + """Serialize the SetSlotsFlowStep object + + Returns: + the SetSlotsFlowStep object as serialized data + """ + 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 new file mode 100644 index 000000000000..7b06bf1c7841 --- /dev/null +++ b/rasa/shared/core/flows/steps/start.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Text, List + +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 + + +@dataclass +class StartFlowStep(InternalFlowStep): + """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_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)] + else: + links = [] + + super().__init__( + idx=0, + custom_id=START_STEP, + description=None, + metadata={}, + next=FlowStepLinks(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..317deccc2755 --- /dev/null +++ b/rasa/shared/core/flows/steps/user_message.py @@ -0,0 +1,142 @@ +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 + ) + + @property + def default_id_postfix(self) -> str: + """Returns the default id postfix of the flow step.""" + return "intent" diff --git a/rasa/shared/core/flows/utils.py b/rasa/shared/core/flows/utils.py deleted file mode 100644 index 250efb93720c..000000000000 --- a/rasa/shared/core/flows/utils.py +++ /dev/null @@ -1,25 +0,0 @@ -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/validation.py b/rasa/shared/core/flows/validation.py new file mode 100644 index 000000000000..326797ed646d --- /dev/null +++ b/rasa/shared/core/flows/validation.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +from typing import Optional, Set, Text + +from rasa.shared.core.flows.flow_step import ( + FlowStep, +) +from rasa.shared.core.flows.flow_step_links import ( + BranchingFlowStepLink, + IfFlowStepLink, + ElseFlowStepLink, +) +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 +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: + """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, BranchingFlowStepLink) + and isinstance(link.target_reference, FlowStepSequence) + and len(link.target_reference.child_steps) == 0 + ): + 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, 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) + + +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: 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: 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/rasa/shared/core/flows/yaml_flows_io.py b/rasa/shared/core/flows/yaml_flows_io.py index 14cf7da82557..76dd0748016d 100644 --- a/rasa/shared/core/flows/yaml_flows_io.py +++ b/rasa/shared/core/flows/yaml_flows_io.py @@ -2,15 +2,17 @@ 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 -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" +KEY_FLOWS = "flows" class YAMLFlowsReader: @@ -100,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/importer.py b/rasa/shared/importers/importer.py index 9cd5904d43ee..eae10ee57a0f 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 @@ -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/rasa.py b/rasa/shared/importers/rasa.py index 355f913aedd4..f592ae9c07ac 100644 --- a/rasa/shared/importers/rasa.py +++ b/rasa/shared/importers/rasa.py @@ -1,7 +1,9 @@ import logging import os from typing import Dict, List, Optional, Text, Union -from rasa.shared.core.flows.flow import FlowsList + +import rasa.shared.core.flows.yaml_flows_io +from rasa.shared.core.flows.flows_list import FlowsList import rasa.shared.data import rasa.shared.utils.common @@ -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 diff --git a/rasa/shared/importers/utils.py b/rasa/shared/importers/utils.py index 019363ba77d6..775035081b09 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 @@ -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/rasa/validator.py b/rasa/validator.py index 285afdf640c7..316328495789 100644 --- a/rasa/validator.py +++ b/rasa/validator.py @@ -7,14 +7,11 @@ from pypred import Predicate import rasa.core.training.story_conflict -from rasa.shared.core.flows.flow import ( - ActionFlowStep, - BranchFlowStep, - CollectInformationFlowStep, - FlowsList, - IfFlowLink, - SetSlotsFlowStep, -) +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.action import ActionFlowStep +from rasa.shared.core.flows.flows_list import FlowsList import rasa.shared.nlu.constants from rasa.shared.constants import ( ASSISTANT_ID_DEFAULT_VALUE, @@ -633,24 +630,26 @@ 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, IfFlowLink): - predicate, all_good = Validator._construct_predicate( - link.condition, step.id + 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 + ) + 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/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 87f4e2551b86..6249bdeee0e8 100644 --- a/tests/core/flows/test_flow.py +++ b/tests/core/flows/test_flow.py @@ -1,6 +1,12 @@ import pytest -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.core.flows.validation import ( + validate_flow, + EmptyStepSequenceException, + EmptyFlowException, +) from rasa.shared.importers.importer import FlowSyncImporter from tests.utilities import flows_from_str @@ -37,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): @@ -105,3 +111,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: + validate_flow(flow) + 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: + validate_flow(flow) + assert e.value.flow_id == "empty_branch_flow" + assert "utter_something" in e.value.step_id diff --git a/tests/core/flows/test_flow_schema.py b/tests/core/flows/test_flow_schema.py new file mode 100644 index 000000000000..55a179326bf6 --- /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) 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/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..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,12 +16,9 @@ ) 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.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 ( 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..1f62846c4d3a 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 @@ -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/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..2e5bc251e494 100644 --- a/tests/dialogue_understanding/generator/test_llm_command_generator.py +++ b/tests/dialogue_understanding/generator/test_llm_command_generator.py @@ -26,11 +26,11 @@ 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 ( - CollectInformationFlowStep, - FlowsList, +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 ( 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..8bfd4ff14533 100644 --- a/tests/dialogue_understanding/stack/frames/test_flow_frame.py +++ b/tests/dialogue_understanding/stack/frames/test_flow_frame.py @@ -6,13 +6,11 @@ UserFlowStackFrame, FlowStackFrameType, ) -from rasa.shared.core.flows.flow import ( - ActionFlowStep, - Flow, - FlowLinks, - FlowsList, - StepSequence, -) +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 FlowStepSequence +from rasa.shared.core.flows.flow import Flow +from rasa.shared.core.flows.flows_list import FlowsList def test_flow_frame_type(): @@ -57,21 +55,21 @@ 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", ) - 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=StepSequence(child_steps=[]), + step_sequence=FlowStepSequence(child_steps=[]), name="foo flow", description="foo flow description", ) @@ -89,13 +87,13 @@ def test_flow_get_step(): custom_id="my_step", description=None, metadata={}, - next=FlowLinks(links=[]), + next=FlowStepLinks(links=[]), ) all_flows = FlowsList( - flows=[ + [ Flow( id="foo", - step_sequence=StepSequence(child_steps=[step]), + step_sequence=FlowStepSequence(child_steps=[step]), name="foo flow", description="foo flow description", ) @@ -107,10 +105,10 @@ 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=StepSequence(child_steps=[]), + step_sequence=FlowStepSequence(child_steps=[]), name="foo flow", description="foo flow description", ) @@ -123,10 +121,10 @@ 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=StepSequence(child_steps=[]), + step_sequence=FlowStepSequence(child_steps=[]), name="foo flow", description="foo flow description", ) 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" 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