diff --git a/data/test_trackers/tracker_moodbot.json b/data/test_trackers/tracker_moodbot.json index acdcac89a5cf..7fc6db7830d7 100644 --- a/data/test_trackers/tracker_moodbot.json +++ b/data/test_trackers/tracker_moodbot.json @@ -34,6 +34,7 @@ "followup_action": null, "slots": { "dialogue_stack": null, + "flow_hashes": null, "name": null, "requested_slot": null, "return_value": null, diff --git a/rasa/core/actions/action.py b/rasa/core/actions/action.py index 28258cfab877..dee8d54ccf25 100644 --- a/rasa/core/actions/action.py +++ b/rasa/core/actions/action.py @@ -57,6 +57,7 @@ ACTION_VALIDATE_SLOT_MAPPINGS, MAPPING_TYPE, SlotMappingType, + KNOWLEDGE_BASE_SLOT_NAMES, ) from rasa.shared.core.domain import Domain from rasa.shared.core.events import ( @@ -1292,7 +1293,9 @@ async def run( executed_custom_actions: Set[Text] = set() user_slots = [ - slot for slot in domain.slots if slot.name not in DEFAULT_SLOT_NAMES + slot + for slot in domain.slots + if slot.name not in DEFAULT_SLOT_NAMES | KNOWLEDGE_BASE_SLOT_NAMES ] for slot in user_slots: diff --git a/rasa/core/actions/action_clean_stack.py b/rasa/core/actions/action_clean_stack.py new file mode 100644 index 000000000000..37a7161af43b --- /dev/null +++ b/rasa/core/actions/action_clean_stack.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from typing import Optional, Dict, Any, List + +from rasa.core.actions import action +from rasa.core.channels import OutputChannel +from rasa.core.nlg import NaturalLanguageGenerator +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.dialogue_understanding.stack.frames import ( + BaseFlowStackFrame, + UserFlowStackFrame, +) +from rasa.dialogue_understanding.stack.frames.flow_stack_frame import FlowStackFrameType +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.trackers import DialogueStateTracker + + +class ActionCleanStack(action.Action): + """Action which cancels a flow from the stack.""" + + def name(self) -> str: + """Return the flow name.""" + return ACTION_CLEAN_STACK + + async def run( + self, + output_channel: OutputChannel, + nlg: NaturalLanguageGenerator, + tracker: DialogueStateTracker, + domain: Domain, + metadata: Optional[Dict[str, Any]] = None, + ) -> List[Event]: + """Clean the stack.""" + stack = DialogueStack.from_tracker(tracker) + + new_frames = [] + # Set all frames to their end step, filter out any non-BaseFlowStackFrames + for frame in stack.frames: + if isinstance(frame, BaseFlowStackFrame): + frame.step_id = ContinueFlowStep.continue_step_for_id(END_STEP) + if isinstance(frame, UserFlowStackFrame): + # Making sure there are no "continue interrupts" triggered + frame.frame_type = FlowStackFrameType.REGULAR + new_frames.append(frame) + new_stack = DialogueStack.from_dict([frame.as_dict() for frame in new_frames]) + + return [SlotSet(DIALOGUE_STACK_SLOT, new_stack.as_dict())] diff --git a/rasa/dialogue_understanding/commands/cancel_flow_command.py b/rasa/dialogue_understanding/commands/cancel_flow_command.py index 9a880111e7fe..125365142713 100644 --- a/rasa/dialogue_understanding/commands/cancel_flow_command.py +++ b/rasa/dialogue_understanding/commands/cancel_flow_command.py @@ -48,9 +48,9 @@ def select_canceled_frames(stack: DialogueStack) -> List[str]: The frames that were canceled.""" canceled_frames = [] # we need to go through the original stack dump in reverse order - # to find the frames that were canceled. we cancel everthing from + # to find the frames that were canceled. we cancel everything from # the top of the stack until we hit the user flow that was canceled. - # this will also cancel any patterns put ontop of that user flow, + # this will also cancel any patterns put on top of that user flow, # e.g. corrections. for frame in reversed(stack.frames): canceled_frames.append(frame.frame_id) diff --git a/rasa/dialogue_understanding/commands/handle_code_change_command.py b/rasa/dialogue_understanding/commands/handle_code_change_command.py new file mode 100644 index 000000000000..c54bce685f17 --- /dev/null +++ b/rasa/dialogue_understanding/commands/handle_code_change_command.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List + +import structlog + +from rasa.dialogue_understanding.commands import Command +from rasa.dialogue_understanding.patterns.code_change import CodeChangeFlowStackFrame +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.trackers import DialogueStateTracker +from rasa.dialogue_understanding.stack.utils import top_user_flow_frame + +structlogger = structlog.get_logger() + + +@dataclass +class HandleCodeChangeCommand(Command): + """A that is executed when the flows have changed.""" + + @classmethod + def command(cls) -> str: + """Returns the command type.""" + return "handle code change" + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> HandleCodeChangeCommand: + """Converts the dictionary to a command. + + Returns: + The converted dictionary. + """ + return HandleCodeChangeCommand() + + def run_command_on_tracker( + self, + tracker: DialogueStateTracker, + all_flows: FlowsList, + original_tracker: DialogueStateTracker, + ) -> List[Event]: + """Runs the command on the tracker. + + Args: + tracker: The tracker to run the command on. + all_flows: All flows in the assistant. + original_tracker: The tracker before any command was executed. + + Returns: + The events to apply to the tracker. + """ + + stack = DialogueStack.from_tracker(tracker) + original_stack = DialogueStack.from_tracker(original_tracker) + user_frame = top_user_flow_frame(original_stack) + current_flow = user_frame.flow(all_flows) if user_frame else None + + if not current_flow: + structlogger.debug( + "handle_code_change_command.skip.no_active_flow", command=self + ) + return [] + + stack.push(CodeChangeFlowStackFrame()) + return [SlotSet(DIALOGUE_STACK_SLOT, stack.as_dict())] diff --git a/rasa/dialogue_understanding/patterns/cancel.py b/rasa/dialogue_understanding/patterns/cancel.py index fa1eda6316c4..76678285cfd5 100644 --- a/rasa/dialogue_understanding/patterns/cancel.py +++ b/rasa/dialogue_understanding/patterns/cancel.py @@ -59,7 +59,7 @@ def from_dict(data: Dict[str, Any]) -> CancelPatternFlowStackFrame: The created `DialogueStackFrame`. """ return CancelPatternFlowStackFrame( - data["frame_id"], + frame_id=data["frame_id"], step_id=data["step_id"], canceled_name=data["canceled_name"], canceled_frames=data["canceled_frames"], diff --git a/rasa/dialogue_understanding/patterns/clarify.py b/rasa/dialogue_understanding/patterns/clarify.py index 4a7b1df074f0..fdf41d79c247 100644 --- a/rasa/dialogue_understanding/patterns/clarify.py +++ b/rasa/dialogue_understanding/patterns/clarify.py @@ -50,7 +50,7 @@ def from_dict(data: Dict[str, Any]) -> ClarifyPatternFlowStackFrame: The created `DialogueStackFrame`. """ return ClarifyPatternFlowStackFrame( - data["frame_id"], + frame_id=data["frame_id"], step_id=data["step_id"], names=data["names"], clarification_options=data["clarification_options"], diff --git a/rasa/dialogue_understanding/patterns/code_change.py b/rasa/dialogue_understanding/patterns/code_change.py new file mode 100644 index 000000000000..4a0ebf12ebeb --- /dev/null +++ b/rasa/dialogue_understanding/patterns/code_change.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict + +import structlog +from rasa.dialogue_understanding.stack.frames import ( + PatternFlowStackFrame, +) +from rasa.shared.constants import RASA_DEFAULT_FLOW_PATTERN_PREFIX + +structlogger = structlog.get_logger() + +FLOW_PATTERN_CODE_CHANGE_ID = RASA_DEFAULT_FLOW_PATTERN_PREFIX + "code_change" + + +@dataclass +class CodeChangeFlowStackFrame(PatternFlowStackFrame): + """A pattern flow stack frame which cleans the stack after a bot update.""" + + flow_id: str = FLOW_PATTERN_CODE_CHANGE_ID + """The ID of the flow.""" + + @classmethod + def type(cls) -> str: + """Returns the type of the frame.""" + return FLOW_PATTERN_CODE_CHANGE_ID + + @staticmethod + def from_dict(data: Dict[str, Any]) -> CodeChangeFlowStackFrame: + """Creates a `DialogueStackFrame` from a dictionary. + + Args: + data: The dictionary to create the `DialogueStackFrame` from. + + Returns: + The created `DialogueStackFrame`. + """ + return CodeChangeFlowStackFrame( + frame_id=data["frame_id"], + step_id=data["step_id"], + ) diff --git a/rasa/dialogue_understanding/patterns/collect_information.py b/rasa/dialogue_understanding/patterns/collect_information.py index 2bda7588a622..9aa35824e888 100644 --- a/rasa/dialogue_understanding/patterns/collect_information.py +++ b/rasa/dialogue_understanding/patterns/collect_information.py @@ -52,7 +52,7 @@ def from_dict(data: Dict[str, Any]) -> CollectInformationPatternFlowStackFrame: ] return CollectInformationPatternFlowStackFrame( - data["frame_id"], + frame_id=data["frame_id"], step_id=data["step_id"], collect=data["collect"], utter=data["utter"], diff --git a/rasa/dialogue_understanding/patterns/completed.py b/rasa/dialogue_understanding/patterns/completed.py index 5852be45c1d8..1392a403804a 100644 --- a/rasa/dialogue_understanding/patterns/completed.py +++ b/rasa/dialogue_understanding/patterns/completed.py @@ -34,7 +34,7 @@ def from_dict(data: Dict[str, Any]) -> CompletedPatternFlowStackFrame: The created `DialogueStackFrame`. """ return CompletedPatternFlowStackFrame( - data["frame_id"], + frame_id=data["frame_id"], step_id=data["step_id"], previous_flow_name=data["previous_flow_name"], ) diff --git a/rasa/dialogue_understanding/patterns/continue_interrupted.py b/rasa/dialogue_understanding/patterns/continue_interrupted.py index 1137408c8383..7a45f9e677b2 100644 --- a/rasa/dialogue_understanding/patterns/continue_interrupted.py +++ b/rasa/dialogue_understanding/patterns/continue_interrupted.py @@ -36,7 +36,7 @@ def from_dict(data: Dict[str, Any]) -> ContinueInterruptedPatternFlowStackFrame: The created `DialogueStackFrame`. """ return ContinueInterruptedPatternFlowStackFrame( - data["frame_id"], + frame_id=data["frame_id"], step_id=data["step_id"], previous_flow_name=data["previous_flow_name"], ) diff --git a/rasa/dialogue_understanding/patterns/correction.py b/rasa/dialogue_understanding/patterns/correction.py index e8626fa6ff20..7dbd5013e667 100644 --- a/rasa/dialogue_understanding/patterns/correction.py +++ b/rasa/dialogue_understanding/patterns/correction.py @@ -75,7 +75,7 @@ def from_dict(data: Dict[Text, Any]) -> CorrectionPatternFlowStackFrame: The created `DialogueStackFrame`. """ return CorrectionPatternFlowStackFrame( - data["frame_id"], + frame_id=data["frame_id"], step_id=data["step_id"], is_reset_only=data["is_reset_only"], corrected_slots=data["corrected_slots"], diff --git a/rasa/dialogue_understanding/patterns/default_flows_for_patterns.yml b/rasa/dialogue_understanding/patterns/default_flows_for_patterns.yml index 917517f928ab..79ec9ca351d1 100644 --- a/rasa/dialogue_understanding/patterns/default_flows_for_patterns.yml +++ b/rasa/dialogue_understanding/patterns/default_flows_for_patterns.yml @@ -1,37 +1,41 @@ version: "3.1" responses: utter_flow_continue_interrupted: - - text: Let's continue with the topic {{ context.previous_flow_name }}. + - text: "Let's continue with {{ context.previous_flow_name }}." metadata: rephrase: True template: jinja utter_corrected_previous_input: - - text: "Ok, I corrected the {{ context.corrected_slots.keys()|join(', ') }}." + - text: "Ok, I am updating {{ context.corrected_slots.keys()|join(', ') }} to {{ context.corrected_slots.values()|join(', ') }} respectively." metadata: rephrase: True template: jinja utter_flow_cancelled_rasa: - - text: Okay, stopping the flow {{ context.canceled_name }}. + - text: "Okay, stopping {{ context.canceled_name }}." metadata: rephrase: True template: jinja utter_can_do_something_else: - - text: "Is there anything else I can help you with?" + - text: "What else I can help you with?" metadata: rephrase: True utter_internal_error_rasa: - - text: Sorry, I'm having trouble understanding you right now. Please try again later. + - text: Sorry, I am having trouble with that. Please try again in a few minutes. utter_clarification_options_rasa: - - text: I'm not sure what you'd like to achieve. Do you want to {{context.clarification_options}}? + - text: "I can help, but I need more information. Which of these would you like to do: {{context.clarification_options}}?" metadata: rephrase: True template: jinja + utter_inform_code_change: + - text: There has been an update to my code. I need to wrap up our running dialogue and start from scratch. + metadata: + rephrase: True slots: confirm_correction: @@ -125,3 +129,12 @@ flows: action: action_listen next: "start" - id: "done" + + pattern_code_change: + description: flow used to clean the stack after a bot update + steps: + - id: "inform_user" + action: utter_inform_code_change + next: "run_cleanup" + - id: "run_cleanup" + action: action_clean_stack diff --git a/rasa/dialogue_understanding/processor/command_processor.py b/rasa/dialogue_understanding/processor/command_processor.py index 4b7ecf7cc46f..e80a9c0998b2 100644 --- a/rasa/dialogue_understanding/processor/command_processor.py +++ b/rasa/dialogue_understanding/processor/command_processor.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Type +from typing import List, Optional, Type, Set, Dict import structlog from rasa.dialogue_understanding.commands import ( @@ -9,6 +9,9 @@ SetSlotCommand, FreeFormAnswerCommand, ) +from rasa.dialogue_understanding.commands.handle_code_change_command import ( + HandleCodeChangeCommand, +) from rasa.dialogue_understanding.patterns.collect_information import ( CollectInformationPatternFlowStackFrame, ) @@ -23,6 +26,7 @@ filled_slots_for_active_flow, top_flow_frame, ) +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, @@ -95,6 +99,39 @@ def validate_state_of_commands(commands: List[Command]) -> None: assert sum(isinstance(c, CorrectSlotsCommand) for c in commands) <= 1 +def find_updated_flows(tracker: DialogueStateTracker, all_flows: FlowsList) -> Set[str]: + """Find the set of updated flows. + + Run through the current dialogue stack and compare the flow hashes of the + flows on the stack with those stored in the tracker. + + Args: + tracker: The tracker. + all_flows: All flows. + + Returns: + A set of flow ids of those flows that have changed + """ + stored_fingerprints: Dict[str, str] = tracker.get_slot(FLOW_HASHES_SLOT) or {} + dialogue_stack = DialogueStack.from_tracker(tracker) + + changed_flows = set() + for frame in dialogue_stack.frames: + if isinstance(frame, BaseFlowStackFrame): + flow = all_flows.flow_by_id(frame.flow_id) + if flow is None or ( + flow.id in stored_fingerprints + and flow.fingerprint != stored_fingerprints[flow.id] + ): + changed_flows.add(frame.flow_id) + return changed_flows + + +def calculate_flow_fingerprints(all_flows: FlowsList) -> Dict[str, str]: + """Calculate fingerprints for all flows.""" + return {flow.id: flow.fingerprint for flow in all_flows.underlying_flows} + + def execute_commands( tracker: DialogueStateTracker, all_flows: FlowsList ) -> List[Event]: @@ -113,7 +150,23 @@ def execute_commands( commands = clean_up_commands(commands, tracker, all_flows) - events: List[Event] = [] + updated_flows = find_updated_flows(tracker, all_flows) + if updated_flows: + # Override commands + structlogger.debug( + "command_executor.running_flows_were_updated", + updated_flow_ids=updated_flows, + ) + commands = [HandleCodeChangeCommand()] + + # store current flow hashes if they changed + new_hashes = calculate_flow_fingerprints(all_flows) + flow_hash_events: List[Event] = [] + if new_hashes != (tracker.get_slot(FLOW_HASHES_SLOT) or {}): + flow_hash_events.append(SlotSet(FLOW_HASHES_SLOT, new_hashes)) + tracker.update_with_events(flow_hash_events, None) + + events: List[Event] = flow_hash_events # commands need to be reversed to make sure they end up in the right order # on the stack. e.g. if there multiple start flow commands, the first one diff --git a/rasa/dialogue_understanding/stack/frames/flow_stack_frame.py b/rasa/dialogue_understanding/stack/frames/flow_stack_frame.py index ceeb4d5bfe39..20b7cfc6b4be 100644 --- a/rasa/dialogue_understanding/stack/frames/flow_stack_frame.py +++ b/rasa/dialogue_understanding/stack/frames/flow_stack_frame.py @@ -143,8 +143,8 @@ def from_dict(data: Dict[str, Any]) -> UserFlowStackFrame: The created `DialogueStackFrame`. """ return UserFlowStackFrame( - data["frame_id"], - data["flow_id"], - data["step_id"], - FlowStackFrameType.from_str(data.get("frame_type")), + frame_id=data["frame_id"], + flow_id=data["flow_id"], + step_id=data["step_id"], + frame_type=FlowStackFrameType.from_str(data.get("frame_type")), ) diff --git a/rasa/shared/core/constants.py b/rasa/shared/core/constants.py index ed2d31b4cee5..412722d9d055 100644 --- a/rasa/shared/core/constants.py +++ b/rasa/shared/core/constants.py @@ -41,6 +41,7 @@ ACTION_CLARIFY_FLOWS = "action_clarify_flows" ACTION_CORRECT_FLOW_SLOT = "action_correct_flow_slot" ACTION_RUN_SLOT_REJECTIONS_NAME = "action_run_slot_rejections" +ACTION_CLEAN_STACK = "action_clean_stack" DEFAULT_ACTION_NAMES = [ @@ -62,6 +63,7 @@ ACTION_CORRECT_FLOW_SLOT, ACTION_CLARIFY_FLOWS, ACTION_RUN_SLOT_REJECTIONS_NAME, + ACTION_CLEAN_STACK, ] ACTION_SHOULD_SEND_DOMAIN = "send_domain" @@ -89,11 +91,9 @@ REQUESTED_SLOT = "requested_slot" DIALOGUE_STACK_SLOT = "dialogue_stack" RETURN_VALUE_SLOT = "return_value" +FLOW_HASHES_SLOT = "flow_hashes" -FLOW_SLOT_NAMES = [ - DIALOGUE_STACK_SLOT, - RETURN_VALUE_SLOT, -] +FLOW_SLOT_NAMES = [DIALOGUE_STACK_SLOT, RETURN_VALUE_SLOT, FLOW_HASHES_SLOT] # slots for knowledge base SLOT_LISTED_ITEMS = "knowledge_base_listed_objects" @@ -101,13 +101,18 @@ SLOT_LAST_OBJECT_TYPE = "knowledge_base_last_object_type" DEFAULT_KNOWLEDGE_BASE_ACTION = "action_query_knowledge_base" +KNOWLEDGE_BASE_SLOT_NAMES = { + SLOT_LISTED_ITEMS, + SLOT_LAST_OBJECT, + SLOT_LAST_OBJECT_TYPE, +} + DEFAULT_SLOT_NAMES = { REQUESTED_SLOT, DIALOGUE_STACK_SLOT, SESSION_START_METADATA_SLOT, - SLOT_LISTED_ITEMS, - SLOT_LAST_OBJECT, - SLOT_LAST_OBJECT_TYPE, + RETURN_VALUE_SLOT, + FLOW_HASHES_SLOT, } diff --git a/rasa/shared/core/domain.py b/rasa/shared/core/domain.py index c105926a09b2..d697c3842a27 100644 --- a/rasa/shared/core/domain.py +++ b/rasa/shared/core/domain.py @@ -37,12 +37,12 @@ IGNORED_INTENTS, RESPONSE_CONDITION, ) -import rasa.shared.core.constants from rasa.shared.core.constants import ( ACTION_SHOULD_SEND_DOMAIN, SlotMappingType, MAPPING_TYPE, MAPPING_CONDITIONS, + KNOWLEDGE_BASE_SLOT_NAMES, ) from rasa.shared.exceptions import ( RasaException, @@ -1030,12 +1030,7 @@ def _add_knowledge_base_slots(self) -> None: ) ) slot_names = [slot.name for slot in self.slots] - knowledge_base_slots = [ - rasa.shared.core.constants.SLOT_LISTED_ITEMS, - rasa.shared.core.constants.SLOT_LAST_OBJECT, - rasa.shared.core.constants.SLOT_LAST_OBJECT_TYPE, - ] - for slot in knowledge_base_slots: + for slot in KNOWLEDGE_BASE_SLOT_NAMES: if slot not in slot_names: self.slots.append( TextSlot(slot, mappings=[], influence_conversation=False) diff --git a/rasa/shared/core/flows/flow.py b/rasa/shared/core/flows/flow.py index 5eb5355ba4b0..e6de628e229f 100644 --- a/rasa/shared/core/flows/flow.py +++ b/rasa/shared/core/flows/flow.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from functools import cached_property from typing import ( Any, Dict, @@ -535,6 +536,11 @@ def steps(self) -> List[FlowStep]: """Returns the steps of the flow.""" return self.step_sequence.steps + @cached_property + def fingerprint(self) -> str: + """Create a fingerprint identifying this step sequence.""" + return rasa.shared.utils.io.deep_container_fingerprint(self.as_json()) + @dataclass class StepSequence: diff --git a/tests/cdu/commands/conftest.py b/tests/cdu/commands/conftest.py new file mode 100644 index 000000000000..9e7437149042 --- /dev/null +++ b/tests/cdu/commands/conftest.py @@ -0,0 +1,43 @@ +import pytest + +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.trackers import DialogueStateTracker +from rasa.shared.nlu.constants import COMMANDS +from tests.utilities import flows_from_str + + +@pytest.fixture +def all_flows() -> FlowsList: + return flows_from_str( + """ + flows: + foo: + steps: + - id: first_step + action: action_listen + bar: + steps: + - id: also_first_step + action: action_listen + """ + ) + + +start_foo_user_uttered = UserUttered( + "start foo", None, None, {COMMANDS: [StartFlowCommand("foo").as_dict()]} +) + +start_bar_user_uttered = UserUttered( + "start bar", None, None, {COMMANDS: [StartFlowCommand("bar").as_dict()]} +) + + +@pytest.fixture +def tracker(all_flows: FlowsList) -> DialogueStateTracker: + # Creates a useful tracker that has a started flow and the current flows hashed + tracker = DialogueStateTracker.from_events("test", evts=[start_foo_user_uttered]) + execute_commands(tracker, all_flows) + return tracker diff --git a/tests/cdu/commands/test_command_processor.py b/tests/cdu/commands/test_command_processor.py new file mode 100644 index 000000000000..e1b32d1b6c2b --- /dev/null +++ b/tests/cdu/commands/test_command_processor.py @@ -0,0 +1,128 @@ +import pytest + +from rasa.dialogue_understanding.patterns.code_change import FLOW_PATTERN_CODE_CHANGE_ID +from rasa.dialogue_understanding.processor.command_processor import ( + execute_commands, + find_updated_flows, +) +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.dialogue_understanding.stack.frames import ( + UserFlowStackFrame, + PatternFlowStackFrame, +) +from rasa.shared.core.constants import FLOW_HASHES_SLOT +from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.trackers import DialogueStateTracker +from tests.cdu.commands.conftest import start_bar_user_uttered +from tests.utilities import flows_from_str + + +def test_properly_prepared_tracker(tracker: DialogueStateTracker): + # flow hashes have been initialized + assert "foo" in tracker.get_slot(FLOW_HASHES_SLOT) + + # foo flow is on the stack + dialogue_stack = DialogueStack.from_tracker(tracker) + assert (top_frame := dialogue_stack.top()) + assert isinstance(top_frame, UserFlowStackFrame) + assert top_frame.flow_id == "foo" + + +def test_detects_no_changes_when_nothing_changed( + tracker: DialogueStateTracker, all_flows: FlowsList +): + assert find_updated_flows(tracker, all_flows) == set() + + +def test_detects_no_changes_for_not_started_flows( + tracker: DialogueStateTracker, +): + bar_changed_flows = flows_from_str( + """ + flows: + foo: + steps: + - id: first_step + action: action_listen + bar: + steps: + - id: also_first_step_BUT_CHANGED + action: action_listen + """ + ) + assert find_updated_flows(tracker, bar_changed_flows) == set() + + +change_cases = { + "step_id_changed": """ + flows: + foo: + steps: + - id: first_step_id_BUT_CHANGED + action: action_listen + bar: + steps: + - id: also_first_step + action: action_listen + """, + "action_changed": """ + flows: + foo: + steps: + - id: first_step_id + action: action_CHANGED + bar: + steps: + - id: also_first_step + action: action_listen + """, + "new_step": """ + flows: + foo: + steps: + - id: first_step_id + action: action_listen + next: second_step_id + - id: second_step_id + action: action_cool_stuff + bar: + steps: + - id: also_first_step + action: action_listen + """, + "flow_removed": """ + flows: + bar: + steps: + - id: also_first_step + action: action_listen + """, +} + + +@pytest.mark.parametrize("case, flow_yaml", list(change_cases.items())) +def test_detects_changes(case: str, flow_yaml: str, tracker: DialogueStateTracker): + all_flows = flows_from_str(flow_yaml) + assert find_updated_flows(tracker, all_flows) == {"foo"} + + +def test_starting_of_another_flow(tracker: DialogueStateTracker, all_flows: FlowsList): + """Tests that commands are not discarded when there is no change.""" + tracker.update_with_events([start_bar_user_uttered], None) + execute_commands(tracker, all_flows) + dialogue_stack = DialogueStack.from_tracker(tracker) + assert len(dialogue_stack.frames) == 2 + assert (top_frame := dialogue_stack.top()) + assert isinstance(top_frame, UserFlowStackFrame) + assert top_frame.flow_id == "bar" + + +def test_stack_cleaning_command_is_applied_on_changes(tracker: DialogueStateTracker): + all_flows = flows_from_str(change_cases["step_id_changed"]) + tracker.update_with_events([start_bar_user_uttered], None) + execute_commands(tracker, all_flows) + dialogue_stack = DialogueStack.from_tracker(tracker) + assert len(dialogue_stack.frames) == 2 + assert (top_frame := dialogue_stack.top()) + assert isinstance(top_frame, PatternFlowStackFrame) + assert top_frame.flow_id == FLOW_PATTERN_CODE_CHANGE_ID diff --git a/tests/cdu/commands/test_handle_code_change_command.py b/tests/cdu/commands/test_handle_code_change_command.py new file mode 100644 index 000000000000..6ab56430d9a4 --- /dev/null +++ b/tests/cdu/commands/test_handle_code_change_command.py @@ -0,0 +1,103 @@ +import pytest + +from rasa.core.channels import CollectingOutputChannel +from rasa.core.nlg import TemplatedNaturalLanguageGenerator +from rasa.dialogue_understanding.commands.handle_code_change_command import ( + HandleCodeChangeCommand, +) +from rasa.core.actions.action_clean_stack import ActionCleanStack + +from rasa.dialogue_understanding.patterns.code_change import FLOW_PATTERN_CODE_CHANGE_ID +from rasa.dialogue_understanding.processor.command_processor import execute_commands +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.dialogue_understanding.stack.frames import ( + UserFlowStackFrame, + PatternFlowStackFrame, +) +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.trackers import DialogueStateTracker +from tests.cdu.commands.test_command_processor import ( + start_bar_user_uttered, + change_cases, +) +from tests.utilities import flows_from_str + + +def test_name_of_command(): + # names of commands should not change as they are part of persisted + # trackers + assert HandleCodeChangeCommand.command() == "handle code change" + + +def test_from_dict(): + assert HandleCodeChangeCommand.from_dict({}) == HandleCodeChangeCommand() + + +def test_run_command_on_tracker(tracker: DialogueStateTracker, all_flows: FlowsList): + command = HandleCodeChangeCommand() + events = command.run_command_on_tracker(tracker, all_flows, tracker) + assert len(events) == 1 + dialogue_stack_event = events[0] + assert isinstance(dialogue_stack_event, SlotSet) + assert dialogue_stack_event.key == "dialogue_stack" + assert len(dialogue_stack_event.value) == 2 + + frame = dialogue_stack_event.value[1] + assert frame["type"] == FLOW_PATTERN_CODE_CHANGE_ID + + +@pytest.fixture +def about_to_be_cleaned_tracker(tracker: DialogueStateTracker, all_flows: FlowsList): + tracker.update_with_events([start_bar_user_uttered], None) + execute_commands(tracker, all_flows) + changed_flows = flows_from_str(change_cases["step_id_changed"]) + execute_commands(tracker, changed_flows) + dialogue_stack = DialogueStack.from_tracker(tracker) + assert len(dialogue_stack.frames) == 3 + + foo_frame = dialogue_stack.frames[0] + assert isinstance(foo_frame, UserFlowStackFrame) + assert foo_frame.flow_id == "foo" + assert foo_frame.step_id == START_STEP + + bar_frame = dialogue_stack.frames[1] + assert isinstance(bar_frame, UserFlowStackFrame) + assert bar_frame.flow_id == "bar" + assert bar_frame.step_id == START_STEP + + stack_clean_frame = dialogue_stack.frames[2] + assert isinstance(stack_clean_frame, PatternFlowStackFrame) + assert stack_clean_frame.flow_id == FLOW_PATTERN_CODE_CHANGE_ID + assert stack_clean_frame.step_id == START_STEP + + return tracker + + +async def test_stack_cleaning_action(about_to_be_cleaned_tracker: DialogueStateTracker): + events = await ActionCleanStack().run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator({}), + about_to_be_cleaned_tracker, + Domain.empty(), + ) + about_to_be_cleaned_tracker.update_with_events(events, None) + + dialogue_stack = DialogueStack.from_tracker(about_to_be_cleaned_tracker) + assert len(dialogue_stack.frames) == 3 + + foo_frame = dialogue_stack.frames[0] + assert isinstance(foo_frame, UserFlowStackFrame) + assert foo_frame.flow_id == "foo" + assert foo_frame.step_id == ContinueFlowStep.continue_step_for_id(END_STEP) + + bar_frame = dialogue_stack.frames[1] + assert isinstance(bar_frame, UserFlowStackFrame) + assert bar_frame.flow_id == "bar" + assert bar_frame.step_id == ContinueFlowStep.continue_step_for_id(END_STEP) diff --git a/tests/core/featurizers/test_tracker_featurizer.py b/tests/core/featurizers/test_tracker_featurizer.py index c65bf18e1a60..2066a65d6cf4 100644 --- a/tests/core/featurizers/test_tracker_featurizer.py +++ b/tests/core/featurizers/test_tracker_featurizer.py @@ -179,14 +179,25 @@ def test_featurize_trackers_with_full_dialogue_tracker_featurizer( }, ] ] - assert actual_features is not None assert len(actual_features) == len(expected_features) for actual, expected in zip(actual_features, expected_features): assert compare_featurized_states(actual, expected) - expected_labels = np.array([[0, 21, 0, 18, 19, 0, 20]]) + expected_labels = np.array( + [ + [ + 0, + moodbot_domain.action_names_or_texts.index("utter_greet"), + 0, + moodbot_domain.action_names_or_texts.index("utter_cheer_up"), + moodbot_domain.action_names_or_texts.index("utter_did_that_help"), + 0, + moodbot_domain.action_names_or_texts.index("utter_goodbye"), + ] + ] + ) assert actual_labels is not None assert len(actual_labels) == 1 for actual, expected in zip(actual_labels, expected_labels): @@ -255,7 +266,19 @@ def test_trackers_ignore_action_unlikely_intent_with_full_dialogue_tracker_featu for actual, expected in zip(actual_features, expected_features): assert compare_featurized_states(actual, expected) - expected_labels = np.array([[0, 21, 0, 18, 19, 0, 20]]) + expected_labels = np.array( + [ + [ + 0, + moodbot_domain.action_names_or_texts.index("utter_greet"), + 0, + moodbot_domain.action_names_or_texts.index("utter_cheer_up"), + moodbot_domain.action_names_or_texts.index("utter_did_that_help"), + 0, + moodbot_domain.action_names_or_texts.index("utter_goodbye"), + ] + ] + ) assert actual_labels is not None assert len(actual_labels) == 1 for actual, expected in zip(actual_labels, expected_labels): @@ -324,7 +347,22 @@ def test_trackers_keep_action_unlikely_intent_with_full_dialogue_tracker_featuri for actual, expected in zip(actual_features, expected_features): assert compare_featurized_states(actual, expected) - expected_labels = np.array([[0, 9, 21, 0, 9, 18, 19, 0, 9, 20]]) + expected_labels = np.array( + [ + [ + 0, + moodbot_domain.action_names_or_texts.index("action_unlikely_intent"), + moodbot_domain.action_names_or_texts.index("utter_greet"), + 0, + moodbot_domain.action_names_or_texts.index("action_unlikely_intent"), + moodbot_domain.action_names_or_texts.index("utter_cheer_up"), + moodbot_domain.action_names_or_texts.index("utter_did_that_help"), + 0, + moodbot_domain.action_names_or_texts.index("action_unlikely_intent"), + moodbot_domain.action_names_or_texts.index("utter_goodbye"), + ] + ] + ) assert actual_labels is not None assert len(actual_labels) == 1 for actual, expected in zip(actual_labels, expected_labels): @@ -832,7 +870,19 @@ def test_featurize_trackers_with_max_history_tracker_featurizer( for actual, expected in zip(actual_features, expected_features): assert compare_featurized_states(actual, expected) - expected_labels = np.array([[0, 21, 0, 18, 19, 0, 20]]).T + expected_labels = np.array( + [ + [ + 0, + moodbot_domain.action_names_or_texts.index("utter_greet"), + 0, + moodbot_domain.action_names_or_texts.index("utter_cheer_up"), + moodbot_domain.action_names_or_texts.index("utter_did_that_help"), + 0, + moodbot_domain.action_names_or_texts.index("utter_goodbye"), + ] + ] + ).T assert actual_labels is not None assert actual_labels.shape == expected_labels.shape @@ -899,7 +949,15 @@ def test_featurize_trackers_ignore_action_unlikely_intent_max_history_featurizer for actual, expected in zip(actual_features, expected_features): assert compare_featurized_states(actual, expected) - expected_labels = np.array([[0, 21, 0]]).T + expected_labels = np.array( + [ + [ + 0, + moodbot_domain.action_names_or_texts.index("utter_greet"), + 0, + ] + ] + ).T assert actual_labels.shape == expected_labels.shape for actual, expected in zip(actual_labels, expected_labels): assert np.all(actual == expected) @@ -971,7 +1029,16 @@ def test_featurize_trackers_keep_action_unlikely_intent_max_history_featurizer( for actual, expected in zip(actual_features, expected_features): assert compare_featurized_states(actual, expected) - expected_labels = np.array([[0, 9, 21, 0]]).T + expected_labels = np.array( + [ + [ + 0, + moodbot_domain.action_names_or_texts.index("action_unlikely_intent"), + moodbot_domain.action_names_or_texts.index("utter_greet"), + 0, + ] + ] + ).T assert actual_labels is not None assert actual_labels.shape == expected_labels.shape for actual, expected in zip(actual_labels, expected_labels): @@ -1088,7 +1155,19 @@ def test_deduplicate_featurize_trackers_with_max_history_tracker_featurizer( for actual, expected in zip(actual_features, expected_features): assert compare_featurized_states(actual, expected) - expected_labels = np.array([[0, 21, 0, 18, 19, 0, 20]]).T + expected_labels = np.array( + [ + [ + 0, + moodbot_domain.action_names_or_texts.index("utter_greet"), + 0, + moodbot_domain.action_names_or_texts.index("utter_cheer_up"), + moodbot_domain.action_names_or_texts.index("utter_did_that_help"), + 0, + moodbot_domain.action_names_or_texts.index("utter_goodbye"), + ] + ] + ).T if not remove_duplicates: expected_labels = np.vstack([expected_labels] * 2) diff --git a/tests/core/test_actions.py b/tests/core/test_actions.py index c240d065f775..85eecd683aa8 100644 --- a/tests/core/test_actions.py +++ b/tests/core/test_actions.py @@ -97,6 +97,8 @@ ACTION_EXTRACT_SLOTS, DIALOGUE_STACK_SLOT, RETURN_VALUE_SLOT, + ACTION_CLEAN_STACK, + FLOW_HASHES_SLOT, ) from rasa.shared.core.trackers import DialogueStateTracker from rasa.shared.exceptions import RasaException @@ -146,7 +148,7 @@ def test_domain_action_instantiation(): for action_name in domain.action_names_or_texts ] - assert len(instantiated_actions) == 21 + assert len(instantiated_actions) == 22 assert instantiated_actions[0].name() == ACTION_LISTEN_NAME assert instantiated_actions[1].name() == ACTION_RESTART_NAME assert instantiated_actions[2].name() == ACTION_SESSION_START_NAME @@ -165,9 +167,10 @@ def test_domain_action_instantiation(): assert instantiated_actions[15].name() == ACTION_CORRECT_FLOW_SLOT assert instantiated_actions[16].name() == ACTION_CLARIFY_FLOWS assert instantiated_actions[17].name() == ACTION_RUN_SLOT_REJECTIONS_NAME - assert instantiated_actions[18].name() == "my_module.ActionTest" - assert instantiated_actions[19].name() == "utter_test" - assert instantiated_actions[20].name() == "utter_chitchat" + assert instantiated_actions[18].name() == ACTION_CLEAN_STACK + assert instantiated_actions[19].name() == "my_module.ActionTest" + assert instantiated_actions[20].name() == "utter_test" + assert instantiated_actions[21].name() == "utter_chitchat" @pytest.mark.parametrize( @@ -248,6 +251,7 @@ async def test_remote_action_runs( "slots": { "name": None, REQUESTED_SLOT: None, + FLOW_HASHES_SLOT: None, SESSION_START_METADATA_SLOT: None, DIALOGUE_STACK_SLOT: None, RETURN_VALUE_SLOT: None, @@ -312,6 +316,7 @@ async def test_remote_action_logs_events( "slots": { "name": None, REQUESTED_SLOT: None, + FLOW_HASHES_SLOT: None, SESSION_START_METADATA_SLOT: None, DIALOGUE_STACK_SLOT: None, RETURN_VALUE_SLOT: None, diff --git a/tests/shared/core/test_domain.py b/tests/shared/core/test_domain.py index b70e1dc5b3a8..5e1d1e4ced66 100644 --- a/tests/shared/core/test_domain.py +++ b/tests/shared/core/test_domain.py @@ -27,6 +27,7 @@ DEFAULT_KNOWLEDGE_BASE_ACTION, ENTITY_LABEL_SEPARATOR, DEFAULT_ACTION_NAMES, + DEFAULT_SLOT_NAMES, ) from rasa.shared.core.domain import ( InvalidDomain, @@ -888,10 +889,9 @@ def test_domain_from_multiple_files(): "utter_default": [{"text": "default message"}], "utter_amazement": [{"text": "awesomness!"}], } - expected_slots = [ + expected_slots = list(DEFAULT_SLOT_NAMES) + [ "activate_double_simulation", "activate_simulation", - "dialogue_stack", "display_cure_method", "display_drum_cure_horns", "display_method_artwork", @@ -914,9 +914,6 @@ def test_domain_from_multiple_files(): "humbleSelectionManagement", "humbleSelectionStatus", "offers", - "requested_slot", - "return_value", - "session_started_metadata", ] domain_slots = [] @@ -930,7 +927,7 @@ def test_domain_from_multiple_files(): assert expected_responses == domain.responses assert expected_forms == domain.forms assert domain.session_config.session_expiration_time == 360 - assert expected_slots == sorted(domain_slots) + assert sorted(expected_slots) == sorted(domain_slots) def test_domain_warnings(domain: Domain): diff --git a/tests/shared/core/training_data/story_reader/test_yaml_story_reader.py b/tests/shared/core/training_data/story_reader/test_yaml_story_reader.py index 1bbb08bdd7d0..c51322b8065e 100644 --- a/tests/shared/core/training_data/story_reader/test_yaml_story_reader.py +++ b/tests/shared/core/training_data/story_reader/test_yaml_story_reader.py @@ -775,12 +775,17 @@ def test_generate_training_data_with_cycles(domain: Domain): # deterministic way but should always be 3 or 4 assert len(training_trackers) == 3 or len(training_trackers) == 4 - # if we have 4 trackers, there is going to be one example more for label 10 - num_tens = len(training_trackers) - 1 - # if new default actions are added the keys of the actions will be changed + # if we have 4 trackers, there is going to be one example more for utter_default + num_utter_default = len(training_trackers) - 1 all_label_ids = [id for ids in label_ids for id in ids] - assert Counter(all_label_ids) == {0: 6, 20: 3, 19: num_tens, 1: 2, 21: 1} + assert Counter(all_label_ids) == { + 0: 6, + domain.action_names_or_texts.index("utter_goodbye"): 3, + domain.action_names_or_texts.index("utter_default"): num_utter_default, + 1: 2, + domain.action_names_or_texts.index("utter_greet"): 1, + } def test_generate_training_data_with_unused_checkpoints(domain: Domain): diff --git a/tests/shared/importers/test_rasa.py b/tests/shared/importers/test_rasa.py index 88025312edf9..a739f1c71c97 100644 --- a/tests/shared/importers/test_rasa.py +++ b/tests/shared/importers/test_rasa.py @@ -11,9 +11,8 @@ from rasa.shared.core.constants import ( DEFAULT_ACTION_NAMES, DEFAULT_INTENTS, - SESSION_START_METADATA_SLOT, - DIALOGUE_STACK_SLOT, - RETURN_VALUE_SLOT, + DEFAULT_SLOT_NAMES, + REQUESTED_SLOT, ) from rasa.shared.core.domain import Domain from rasa.shared.core.slots import AnySlot @@ -30,11 +29,14 @@ def test_rasa_file_importer(project: Text): domain = importer.get_domain() assert len(domain.intents) == 7 + len(DEFAULT_INTENTS) - assert domain.slots == [ - AnySlot(DIALOGUE_STACK_SLOT, mappings=[{}]), - AnySlot(RETURN_VALUE_SLOT, mappings=[{}]), - AnySlot(SESSION_START_METADATA_SLOT, mappings=[{}]), + default_slots = [ + AnySlot(slot_name, mappings=[{}]) + for slot_name in DEFAULT_SLOT_NAMES + if slot_name != REQUESTED_SLOT ] + assert sorted(domain.slots, key=lambda s: s.name) == sorted( + default_slots, key=lambda s: s.name + ) assert domain.entities == [] assert len(domain.action_names_or_texts) == 6 + len(DEFAULT_ACTION_NAMES) diff --git a/tests/test_server.py b/tests/test_server.py index 8467693ed24e..f7df6c69fb6f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -57,6 +57,7 @@ SESSION_START_METADATA_SLOT, DIALOGUE_STACK_SLOT, RETURN_VALUE_SLOT, + FLOW_HASHES_SLOT, ) from rasa.shared.core.domain import Domain, SessionConfig from rasa.shared.core.events import ( @@ -1117,6 +1118,7 @@ async def test_requesting_non_existent_tracker(rasa_app: SanicASGITestClient): assert content["slots"] == { "name": None, REQUESTED_SLOT: None, + FLOW_HASHES_SLOT: None, SESSION_START_METADATA_SLOT: None, DIALOGUE_STACK_SLOT: None, RETURN_VALUE_SLOT: None,