diff --git a/changelog/4964.feature.rst b/changelog/4964.feature.rst new file mode 100644 index 000000000000..07f9a67ca5e7 --- /dev/null +++ b/changelog/4964.feature.rst @@ -0,0 +1,5 @@ +External events and reminders now trigger intents (and entities) instead of actions. + +Add new endpoint ``/conversations//trigger_intent``, which lets the user specify an intent and a +list of entities that is injected into the conversation in place of a user message. The bot then predicts and +executes a response action. diff --git a/changelog/4964.improvement.rst b/changelog/4964.improvement.rst new file mode 100644 index 000000000000..f9e34675c854 --- /dev/null +++ b/changelog/4964.improvement.rst @@ -0,0 +1,2 @@ +``ReminderCancelled`` can now cancel multiple reminders if no name is given. It still cancels a single +reminder if the reminder's name is specified. \ No newline at end of file diff --git a/changelog/4964.removal.rst b/changelog/4964.removal.rst new file mode 100644 index 000000000000..23bc902bf1ce --- /dev/null +++ b/changelog/4964.removal.rst @@ -0,0 +1,2 @@ +The endpoint ``/conversations//execute`` is now deprecated. Instead, users should use +the ``/conversations//trigger_intent`` endpoint and thus trigger intents instead of actions. \ No newline at end of file diff --git a/docs/_static/spec/rasa.yml b/docs/_static/spec/rasa.yml index 7139c7f38db6..c119cee26ca1 100644 --- a/docs/_static/spec/rasa.yml +++ b/docs/_static/spec/rasa.yml @@ -255,8 +255,9 @@ paths: tags: - Tracker summary: Run an action in a conversation + deprecated: true description: >- - Runs the action, calling the action server if necessary. + DEPRECATED. Runs the action, calling the action server if necessary. Any responses sent by the executed action will be forwarded to the channel specified in the output_channel parameter. If no output channel is specified, any messages that should be @@ -296,6 +297,57 @@ paths: 500: $ref: '#/components/responses/500ServerError' + /conversations/{conversation_id}/trigger_intent: + post: + security: + - TokenAuth: [] + - JWT: [] + operationId: triggerConversationIntent + tags: + - Tracker + summary: Inject an intent into a conversation + description: >- + Sends a specified intent and list of entities in place of a + user message. The bot then predicts and executes a response action. + Any responses sent by the executed action will be forwarded + to the channel specified in the ``output_channel`` parameter. + If no output channel is specified, any messages that should be + sent to the user will be included in the response of this endpoint. + parameters: + - $ref: '#/components/parameters/conversation_id' + - $ref: '#/components/parameters/include_events' + - $ref: '#/components/parameters/output_channel' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/IntentTriggerRequest' + responses: + 200: + description: Success + content: + application/json: + schema: + type: object + properties: + tracker: + $ref: '#/components/schemas/Tracker' + messages: + type: array + items: + $ref: '#/components/schemas/BotMessage' + 400: + $ref: '#/components/responses/400BadRequest' + 401: + $ref: '#/components/responses/401NotAuthenticated' + 403: + $ref: '#/components/responses/403NotAuthorized' + 409: + $ref: '#/components/responses/409Conflict' + 500: + $ref: '#/components/responses/500ServerError' + /conversations/{conversation_id}/predict: post: security: @@ -905,21 +957,34 @@ components: type: object properties: name: - description: >- - Name of the action to be executed. + description: Name of the action to be executed. type: string example: utter_greet policy: - description: >- - Name of the policy that predicted the action (optional). + description: Name of the policy that predicted the action. type: string + nullable: true confidence: - description: >- - Confidence of the prediction (optional). + description: Confidence of the prediction. type: number + nullable: true example: 0.987232 required: ["name"] + IntentTriggerRequest: + type: object + properties: + name: + description: Name of the intent to be executed. + type: string + example: greet + entities: + description: Entities to be passed on. + type: object + nullable: true + example: {"temperature": "high"} + required: ["name"] + Message: type: object properties: @@ -1592,4 +1657,3 @@ components: properties: use_entities: type: boolean - diff --git a/docs/api/events.rst b/docs/api/events.rst index 6fcc17611405..7bf83d67dd9e 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -88,7 +88,7 @@ Reset all Slots Schedule a reminder ~~~~~~~~~~~~~~~~~~~ -:Short: Schedule an action to be executed in the future. +:Short: Schedule an intent to be triggered in the future. :JSON: .. literalinclude:: ../../tests/core/test_events.py :lines: 1- @@ -99,8 +99,33 @@ Schedule a reminder .. autoclass:: rasa.core.events.ReminderScheduled :Effect: - When added to a tracker, core will schedule the action to be - run in the future. + When added to a tracker, Rasa Core will schedule the intent (and entities) to be + triggered in the future, in place of a user input. You can link + this intent to an action of your choice using the :ref:`mapping-policy`. + + +Cancel a reminder +~~~~~~~~~~~~~~~~~~~ + +:Short: Cancel one or more reminders. +:JSON: + .. literalinclude:: ../../tests/core/test_events.py + :lines: 1- + :start-after: # DOCS MARKER ReminderCancelled + :dedent: 4 + :end-before: # DOCS END +:Class: + .. autoclass:: rasa.core.events.ReminderCancelled + +:Effect: + When added to a tracker, Rasa Core will cancel any outstanding reminders that + match the ``ReminderCancelled`` event. For example, + + - ``ReminderCancelled(intent="greet")`` cancels all reminders with intent ``greet`` + - ``ReminderCancelled(entities={...})`` cancels all reminders with the given entities + - ``ReminderCancelled("...")`` cancels the one unique reminder with the given name + - ``ReminderCancelled()`` cancels all reminders + Pause a conversation ~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/core/actions.rst b/docs/core/actions.rst index 281485cd744a..94e32d2c93f8 100644 --- a/docs/core/actions.rst +++ b/docs/core/actions.rst @@ -102,36 +102,6 @@ e.g. by setting slots and send responses back to the user. All of the modifications are done using events. There is a list of all possible event types in :ref:`events`. -Proactively Reaching Out to the User Using Actions --------------------------------------------------- - -You may want to proactively reach out to the user, -for example to display the output of a long running background operation -or notify the user of an external event. - -To do so, you can ``POST`` to this -`endpoint <../../api/http-api/#operation/executeConversationAction>`_ , -specifying the action which should be run for a specific user in the request body. Use the -``output_channel`` query parameter to specify which output -channel should be used to communicate the assistant's responses back to the user. -If your message is static, you can define an ``utter_`` action in your domain file with -a corresponding response. If you need more control, add a custom action in your -domain and implement the required steps in your action server. Any messages which are -dispatched in the custom action will be forwarded to the specified output channel. - - -Proactively reaching out to the user is dependent on the abilities of a channel and -hence not supported by every channel. If your channel does not support it, consider -using the :ref:`callbackInput` channel to send messages to a webhook. - - -.. note:: - - Running an action in a conversation changes the conversation history and affects the - assistant's next predictions. If you don't want this to happen, make sure that your action - reverts itself by appending a ``ActionReverted`` event to the end of the - conversation tracker. - .. _default-actions: Default Actions diff --git a/docs/core/responses.rst b/docs/core/responses.rst index 56db605f154b..2342c503eac7 100644 --- a/docs/core/responses.rst +++ b/docs/core/responses.rst @@ -156,3 +156,32 @@ The endpoint then needs to respond with the generated response: } Rasa will then use this response and sent it back to the user. + + +.. _external-events: + +Proactively Reaching Out to the User with External Events +--------------------------------------------------------- + +You may want to proactively reach out to the user, +for example to display the output of a long running background operation +or notify the user of an external event. + +To do so, you can ``POST`` an intent to the +`trigger_intent endpoint <../../api/http-api/#operation/triggerConversationIntent>`_. +The intent, let's call it ``EXTERNAL_sensor``, will be treated as if the user had sent a message with this intent. +You can even provide a dictionary of entities as parameters, e.g. ``{"temperature": "high"}``. +For your bot to respond, we recommend you use the :ref:`mapping-policy` to connect the sent intent ``EXTERNAL_sensor`` +with the action you want your bot to execute, e.g. ``utter_warn_temperature``. +You can also use a custom action here, of course. + +Use the ``output_channel`` query parameter to specify which output +channel should be used to communicate the assistant's responses back to the user. +Any messages that are dispatched in the custom action will be forwarded to the specified output channel. +Set this parameter to ``"latest"`` if you want to use the latest input channel that the user has used. + +.. note:: + + Proactively reaching out to the user is dependent on the abilities of a channel and + hence not supported by every channel. If your channel does not support it, consider + using the :ref:`callbackInput` channel to send messages to a webhook. diff --git a/rasa/core/agent.py b/rasa/core/agent.py index b35765e31475..594186ae0225 100644 --- a/rasa/core/agent.py +++ b/rasa/core/agent.py @@ -521,6 +521,20 @@ async def execute_action( sender_id, action, output_channel, self.nlg, policy, confidence ) + async def trigger_intent( + self, + intent_name: Text, + entities: List[Dict[Text, Any]], + output_channel: OutputChannel, + tracker: DialogueStateTracker, + ) -> None: + """Trigger a user intent, e.g. triggered by an external event.""" + + processor = self.create_processor() + await processor.trigger_external_user_uttered( + intent_name, entities, tracker, output_channel, + ) + async def handle_text( self, text_message: Union[Text, Dict[Text, Any]], diff --git a/rasa/core/constants.py b/rasa/core/constants.py index d8a6da541573..02188d8aa8c0 100644 --- a/rasa/core/constants.py +++ b/rasa/core/constants.py @@ -24,6 +24,7 @@ # start of special user message section INTENT_MESSAGE_PREFIX = "/" +EXTERNAL_MESSAGE_PREFIX = "EXTERNAL: " USER_INTENT_RESTART = "restart" @@ -37,6 +38,9 @@ BEARER_TOKEN_PREFIX = "Bearer " +# Key to access data in the event metadata which specifies if an event was caused by an external entity (e.g. a sensor). +IS_EXTERNAL = "is_external" + # the lowest priority intended to be used by machine learning policies DEFAULT_POLICY_PRIORITY = 1 # the priority intended to be used by mapping policies diff --git a/rasa/core/events/__init__.py b/rasa/core/events/__init__.py index 35af7c297205..8b30613c9df9 100644 --- a/rasa/core/events/__init__.py +++ b/rasa/core/events/__init__.py @@ -1,6 +1,6 @@ import json import logging -import warnings +import re import jsonpickle import time @@ -13,6 +13,12 @@ from rasa.core import utils from typing import Union +from rasa.core.constants import ( + IS_EXTERNAL, + EXTERNAL_MESSAGE_PREFIX, + ACTION_NAME_SENDER_ID_CONNECTOR_STR, +) + if typing.TYPE_CHECKING: from rasa.core.trackers import DialogueStateTracker @@ -203,8 +209,8 @@ class UserUttered(Event): def __init__( self, text: Optional[Text] = None, - intent=None, - entities=None, + intent: Optional[Dict] = None, + entities: Optional[List[Dict]] = None, parse_data: Optional[Dict[Text, Any]] = None, timestamp: Optional[float] = None, input_channel: Optional[Text] = None, @@ -332,6 +338,17 @@ def apply_to(self, tracker: "DialogueStateTracker") -> None: tracker.latest_message = self tracker.clear_followup_action() + @staticmethod + def create_external( + intent_name: Text, entity_list: Optional[List[Dict[Text, Any]]] = None, + ) -> "UserUttered": + return UserUttered( + text=f"{EXTERNAL_MESSAGE_PREFIX}{intent_name}", + intent={"name": intent_name}, + metadata={IS_EXTERNAL: True}, + entities=entity_list or [], + ) + # noinspection PyProtectedMember class BotUttered(Event): @@ -580,17 +597,16 @@ def apply_to(self, tracker) -> None: # noinspection PyProtectedMember class ReminderScheduled(Event): - """ Allows asynchronous scheduling of action execution. - - As a side effect the message processor will schedule an action to be run - at the trigger date.""" + """Schedules the asynchronous triggering of a user intent + (with entities if needed) at a given time.""" type_name = "reminder" def __init__( self, - action_name: Text, + intent: Text, trigger_date_time: datetime, + entities: Optional[List[Dict]] = None, name: Optional[Text] = None, kill_on_user_message: bool = True, timestamp: Optional[float] = None, @@ -599,18 +615,20 @@ def __init__( """Creates the reminder Args: - action_name: name of the action to be scheduled - trigger_date_time: date at which the execution of the action - should be triggered (either utc or with tz) - name: id of the reminder. if there are multiple reminders with - the same id only the last will be run + intent: Name of the intent to be triggered. + trigger_date_time: Date at which the execution of the action + should be triggered (either utc or with tz). + name: ID of the reminder. If there are multiple reminders with + the same id only the last will be run. + entities: Entities that should be supplied together with the + triggered intent. kill_on_user_message: ``True`` means a user message before the - trigger date will abort the reminder - timestamp: creation date of the event - metadata: optional event metadata + trigger date will abort the reminder. + timestamp: Creation date of the event. + metadata: Optional event metadata. """ - - self.action_name = action_name + self.intent = intent + self.entities = entities self.trigger_date_time = trigger_date_time self.kill_on_user_message = kill_on_user_message self.name = name if name is not None else str(uuid.uuid1()) @@ -619,7 +637,8 @@ def __init__( def __hash__(self) -> int: return hash( ( - self.action_name, + self.intent, + self.entities, self.trigger_date_time.isoformat(), self.kill_on_user_message, self.name, @@ -632,87 +651,152 @@ def __eq__(self, other) -> bool: else: return self.name == other.name - def __str__(self) -> str: + def __str__(self) -> Text: return ( - "ReminderScheduled(" - "action: {}, trigger_date: {}, name: {}" - ")".format(self.action_name, self.trigger_date_time, self.name) + f"ReminderScheduled(intent: {self.intent}, trigger_date: {self.trigger_date_time}, " + f"entities: {self.entities}, name: {self.name})" ) - def _data_obj(self) -> Dict[Text, Any]: + def scheduled_job_name(self, sender_id: Text) -> Text: + return ( + f"[{hash(self.name)},{hash(self.intent)},{hash(str(self.entities))}]" + f"{ACTION_NAME_SENDER_ID_CONNECTOR_STR}" + f"{sender_id}" + ) + + def _properties(self) -> Dict[Text, Any]: return { - "action": self.action_name, + "intent": self.intent, "date_time": self.trigger_date_time.isoformat(), + "entities": self.entities, "name": self.name, "kill_on_user_msg": self.kill_on_user_message, } def as_story_string(self) -> Text: - props = json.dumps(self._data_obj()) + props = json.dumps(self._properties()) return f"{self.type_name}{props}" def as_dict(self) -> Dict[Text, Any]: d = super().as_dict() - d.update(self._data_obj()) + d.update(self._properties()) return d @classmethod def _from_story_string(cls, parameters: Dict[Text, Any]) -> Optional[List[Event]]: trigger_date_time = parser.parse(parameters.get("date_time")) + return [ ReminderScheduled( - parameters.get("action"), + parameters.get("intent"), trigger_date_time, - parameters.get("name", None), - parameters.get("kill_on_user_msg", True), - parameters.get("timestamp"), - parameters.get("metadata"), + parameters.get("entities"), + name=parameters.get("name"), + kill_on_user_message=parameters.get("kill_on_user_msg", True), + timestamp=parameters.get("timestamp"), + metadata=parameters.get("metadata"), ) ] # noinspection PyProtectedMember class ReminderCancelled(Event): - """Cancel all jobs with a specific name.""" + """Cancel certain jobs.""" type_name = "cancel_reminder" def __init__( self, - action_name: Text, + name: Optional[Text] = None, + intent: Optional[Text] = None, + entities: Optional[List[Dict]] = None, timestamp: Optional[float] = None, metadata: Optional[Dict[Text, Any]] = None, ): - """ + """Creates a ReminderCancelled event. + + If all arguments are `None`, this will cancel all reminders. + are to be cancelled. If no arguments are supplied, this will cancel all reminders. + Args: - action_name: name of the scheduled action to be cancelled - metadata: optional event metadata + name: Name of the reminder to be cancelled. + intent: Intent name that is to be used to identify the reminders to be cancelled. + entities: Entities that are to be used to identify the reminders to be cancelled. + timestamp: Optional timestamp. + metadata: Optional event metadata. """ - self.action_name = action_name + self.name = name + self.intent = intent + self.entities = entities super().__init__(timestamp, metadata) def __hash__(self) -> int: - return hash(self.action_name) + return hash((self.name, self.intent, str(self.entities),)) - def __eq__(self, other) -> bool: - return isinstance(other, ReminderCancelled) + def __eq__(self, other: Any) -> bool: + if not isinstance(other, ReminderCancelled): + return False + else: + return hash(self) == hash(other) def __str__(self) -> Text: - return f"ReminderCancelled(action: {self.action_name})" + return f"ReminderCancelled(name: {self.name}, intent: {self.intent}, entities: {self.entities})" + + def cancels_job_with_name(self, job_name: Text, sender_id: Text) -> bool: + """Determines if this `ReminderCancelled` event should cancel the job with the given name. + + Args: + job_name: Name of the job to be tested. + sender_id: The `sender_id` of the tracker. + + Returns: + `True`, if this `ReminderCancelled` event should cancel the job with the given name, + and `False` otherwise. + """ + + match = re.match( + rf"^\[([\d\-]*),([\d\-]*),([\d\-]*)\]" + rf"({re.escape(ACTION_NAME_SENDER_ID_CONNECTOR_STR)}{re.escape(sender_id)})", + job_name, + ) + if not match: + return False + name_hash, intent_hash, entities_hash = match.group(1, 2, 3) + + # Cancel everything unless names/intents/entities are given to + # narrow it down. + return ( + ((not self.name) or self._matches_name_hash(name_hash)) + and ((not self.intent) or self._matches_intent_hash(intent_hash)) + and ((not self.entities) or self._matches_entities_hash(entities_hash)) + ) + + def _matches_name_hash(self, name_hash: Text) -> bool: + return str(hash(self.name)) == name_hash + + def _matches_intent_hash(self, intent_hash: Text) -> bool: + return str(hash(self.intent)) == intent_hash + + def _matches_entities_hash(self, entities_hash: Text) -> bool: + return str(hash(str(self.entities))) == entities_hash def as_story_string(self) -> Text: - props = json.dumps({"action": self.action_name}) + props = json.dumps( + {"name": self.name, "intent": self.intent, "entities": self.entities} + ) return f"{self.type_name}{props}" @classmethod def _from_story_string(cls, parameters: Dict[Text, Any]) -> Optional[List[Event]]: return [ ReminderCancelled( - parameters.get("action"), - parameters.get("timestamp"), - parameters.get("metadata"), + parameters.get("name"), + parameters.get("intent"), + parameters.get("entities"), + timestamp=parameters.get("timestamp"), + metadata=parameters.get("metadata"), ) ] diff --git a/rasa/core/processor.py b/rasa/core/processor.py index c01987f62cf8..a0f7f5f75304 100644 --- a/rasa/core/processor.py +++ b/rasa/core/processor.py @@ -2,7 +2,7 @@ import logging import os from types import LambdaType -from typing import Any, Dict, List, Optional, Text, Tuple +from typing import Any, Dict, List, Optional, Text, Tuple, Union import numpy as np import time @@ -16,7 +16,6 @@ OutputChannel, ) from rasa.core.constants import ( - ACTION_NAME_SENDER_ID_CONNECTOR_STR, USER_INTENT_RESTART, UTTER_PREFIX, USER_INTENT_BACK, @@ -47,7 +46,6 @@ logger = logging.getLogger(__name__) - MAX_NUMBER_OF_PREDICTIONS = int(os.environ.get("MAX_NUMBER_OF_PREDICTIONS", "10")) DEFAULT_INTENTS = [ @@ -100,7 +98,7 @@ async def handle_message( ) return None - await self._predict_and_execute_next_action(message, tracker) + await self._predict_and_execute_next_action(message.output_channel, tracker) # save tracker state to continue conversation from this state self._save_tracker(tracker) @@ -324,22 +322,53 @@ async def handle_reminder( or not self._is_reminder_still_valid(tracker, reminder_event) ): logger.debug( - f"Canceled reminder because it is outdated. " - f"(event: {reminder_event.action_name} id: {reminder_event.name})" + f"Canceled reminder because it is outdated. " f"({reminder_event})" ) else: - # necessary for proper featurization, otherwise the previous - # unrelated message would influence featurization - tracker.update(UserUttered.empty()) - action = self._get_action(reminder_event.action_name) - should_continue = await self._run_action( - action, tracker, output_channel, nlg + intent = reminder_event.intent + entities = reminder_event.entities or {} + await self.trigger_external_user_uttered( + intent, entities, tracker, output_channel ) - if should_continue: - user_msg = UserMessage(None, output_channel, sender_id) - await self._predict_and_execute_next_action(user_msg, tracker) - # save tracker state to continue conversation from this state - self._save_tracker(tracker) + + async def trigger_external_user_uttered( + self, + intent_name: Text, + entities: Optional[Union[List[Dict[Text, Any]], Dict[Text, Text]]], + tracker: DialogueStateTracker, + output_channel: OutputChannel, + ) -> None: + """Triggers an external message. + + Triggers an external message (like a user message, but invisible; + used, e.g., by a reminder or the trigger_intent endpoint). + + Args: + intent_name: Name of the intent to be triggered. + entities: Entities to be passed on. + tracker: The tracker to which the event should be added. + output_channel: The output channel. + """ + if isinstance(entities, list): + entity_list = entities + elif isinstance(entities, dict): + # Allow for a short-hand notation {"ent1": "val1", "ent2": "val2", ...}. + # Useful if properties like 'start', 'end', or 'extractor' are not given, + # e.g. for external events. + entity_list = [ + {"entity": ent, "value": val} for ent, val in entities.items() + ] + elif not entities: + entity_list = [] + else: + warnings.warn( + f"Invalid entity specification: {entities}. Assuming no entities." + ) + entity_list = [] + tracker.update(UserUttered.create_external(intent_name, entity_list)) + await self._predict_and_execute_next_action(output_channel, tracker) + # save tracker state to continue conversation from this state + self._save_tracker(tracker) @staticmethod def _log_slots(tracker) -> None: @@ -442,7 +471,7 @@ def _should_handle_message(tracker: DialogueStateTracker): ) async def _predict_and_execute_next_action( - self, message: UserMessage, tracker: DialogueStateTracker + self, output_channel: OutputChannel, tracker: DialogueStateTracker ): # keep taking actions decided by the policy until it chooses to 'listen' should_predict_another_action = True @@ -464,7 +493,7 @@ def is_action_limit_reached(): action, policy, confidence = self.predict_next_action(tracker) should_predict_another_action = await self._run_action( - action, tracker, message.output_channel, self.nlg, policy, confidence + action, tracker, output_channel, self.nlg, policy, confidence ) num_predicted_actions += 1 @@ -476,7 +505,7 @@ def is_action_limit_reached(): ) if self.on_circuit_break: # call a registered callback - self.on_circuit_break(tracker, message.output_channel, self.nlg) + self.on_circuit_break(tracker, output_channel, self.nlg) @staticmethod def should_predict_another_action(action_name: Text) -> bool: @@ -529,31 +558,24 @@ async def _schedule_reminders( args=[e, tracker.sender_id, output_channel, nlg], id=e.name, replace_existing=True, - name=( - str(e.action_name) - + ACTION_NAME_SENDER_ID_CONNECTOR_STR - + tracker.sender_id - ), + name=e.scheduled_job_name(tracker.sender_id), ) @staticmethod async def _cancel_reminders( events: List[Event], tracker: DialogueStateTracker ) -> None: - """Cancel reminders by action_name""" + """Cancel reminders that match the `ReminderCancelled` event.""" - # All Reminders with the same action name will be cancelled - for e in events: - if isinstance(e, ReminderCancelled): - name_to_check = ( - str(e.action_name) - + ACTION_NAME_SENDER_ID_CONNECTOR_STR - + tracker.sender_id - ) + # All Reminders specified by ReminderCancelled events will be cancelled + for event in events: + if isinstance(event, ReminderCancelled): scheduler = await jobs.scheduler() - for j in scheduler.get_jobs(): - if j.name == name_to_check: - scheduler.remove_job(j.id) + for scheduled_job in scheduler.get_jobs(): + if event.cancels_job_with_name( + scheduled_job.name, tracker.sender_id + ): + scheduler.remove_job(scheduled_job.id) async def _run_action( self, action, tracker, output_channel, nlg, policy=None, confidence=None diff --git a/rasa/server.py b/rasa/server.py index 97a5a2a8c4e4..c1e49cac2e36 100644 --- a/rasa/server.py +++ b/rasa/server.py @@ -11,6 +11,7 @@ from sanic import Sanic, response from sanic.request import Request +from sanic.response import HTTPResponse from sanic_cors import CORS from sanic_jwt import Initialize, exceptions @@ -581,6 +582,13 @@ async def execute_action(request: Request, conversation_id: Text): {"parameter": "name", "in": "body"}, ) + # Deprecation warning + warnings.warn( + "Triggering actions via the execute endpoint is deprecated. " + "Trigger an intent via the `/conversations//trigger_intent` endpoint instead.", + FutureWarning, + ) + policy = request_params.get("policy", None) confidence = request_params.get("confidence", None) verbosity = event_verbosity_parameter(request, EventVerbosity.AFTER_RESTART) @@ -615,6 +623,60 @@ async def execute_action(request: Request, conversation_id: Text): return response.json(response_body) + @app.post("/conversations//trigger_intent") + @requires_auth(app, auth_token) + @ensure_loaded_agent(app) + async def trigger_intent(request: Request, conversation_id: Text) -> HTTPResponse: + request_params = request.json + + intent_to_trigger = request_params.get("name") + entities = request_params.get("entities", []) + + if not intent_to_trigger: + raise ErrorResponse( + 400, + "BadRequest", + "Name of the intent not provided in request body.", + {"parameter": "name", "in": "body"}, + ) + + verbosity = event_verbosity_parameter(request, EventVerbosity.AFTER_RESTART) + + try: + async with app.agent.lock_store.lock(conversation_id): + tracker = await get_tracker( + app.agent.create_processor(), conversation_id + ) + output_channel = _get_output_channel(request, tracker) + if intent_to_trigger not in app.agent.domain.intents: + raise ErrorResponse( + 404, + "NotFound", + f"The intent {trigger_intent} does not exist in the domain.", + ) + await app.agent.trigger_intent( + intent_name=intent_to_trigger, + entities=entities, + output_channel=output_channel, + tracker=tracker, + ) + except ErrorResponse: + raise + except Exception as e: + logger.debug(traceback.format_exc()) + raise ErrorResponse( + 500, "ConversationError", f"An unexpected error occurred. Error: {e}" + ) + + state = tracker.current_state(verbosity) + + response_body = {"tracker": state} + + if isinstance(output_channel, CollectingOutputChannel): + response_body["messages"] = output_channel.messages + + return response.json(response_body) + @app.post("/conversations//predict") @requires_auth(app, auth_token) @ensure_loaded_agent(app) diff --git a/tests/core/conftest.py b/tests/core/conftest.py index 3003656f043c..a58f80f92706 100644 --- a/tests/core/conftest.py +++ b/tests/core/conftest.py @@ -1,5 +1,7 @@ import asyncio import os +import uuid +from datetime import datetime from typing import Text @@ -10,6 +12,7 @@ from rasa.core.agent import Agent from rasa.core.channels.channel import CollectingOutputChannel, OutputChannel from rasa.core.domain import Domain, SessionConfig +from rasa.core.events import ReminderScheduled, UserUttered, ActionExecuted from rasa.core.interpreter import RegexInterpreter from rasa.core.nlg import TemplatedNaturalLanguageGenerator from rasa.core.policies.ensemble import PolicyEnsemble, SimplePolicyEnsemble @@ -186,6 +189,47 @@ async def default_processor(default_domain, default_nlg): ) +@pytest.fixture +def tracker_with_six_scheduled_reminders( + default_processor: MessageProcessor, +) -> DialogueStateTracker: + reminders = [ + ReminderScheduled("greet", datetime.now(), kill_on_user_message=False), + ReminderScheduled( + intent="greet", + entities=[{"entity": "name", "value": "Jane Doe"}], + trigger_date_time=datetime.now(), + kill_on_user_message=False, + ), + ReminderScheduled( + intent="default", + entities=[{"entity": "name", "value": "Jane Doe"}], + trigger_date_time=datetime.now(), + kill_on_user_message=False, + ), + ReminderScheduled( + intent="greet", + entities=[{"entity": "name", "value": "Bruce Wayne"}], + trigger_date_time=datetime.now(), + kill_on_user_message=False, + ), + ReminderScheduled("default", datetime.now(), kill_on_user_message=False), + ReminderScheduled( + "default", datetime.now(), kill_on_user_message=False, name="special", + ), + ] + sender_id = uuid.uuid4().hex + tracker = default_processor.tracker_store.get_or_create_tracker(sender_id) + for reminder in reminders: + tracker.update(UserUttered("test")) + tracker.update(ActionExecuted("action_reminder_reminder")) + tracker.update(reminder) + + default_processor.tracker_store.save(tracker) + + return tracker + + @pytest.fixture(scope="session") def moodbot_domain(trained_moodbot_path): domain_path = os.path.join("examples", "moodbot", "domain.yml") diff --git a/tests/core/test_events.py b/tests/core/test_events.py index 7e8c5be800a0..88eb99bcde70 100644 --- a/tests/core/test_events.py +++ b/tests/core/test_events.py @@ -16,6 +16,7 @@ ActionExecuted, AllSlotsReset, ReminderScheduled, + ReminderCancelled, ConversationResumed, ConversationPaused, StoryExported, @@ -55,8 +56,8 @@ AgentUttered("my_other_test", "my_other_data"), ), ( - ReminderScheduled("my_action", datetime.now()), - ReminderScheduled("my_other_action", datetime.now()), + ReminderScheduled("my_intent", datetime.now()), + ReminderScheduled("my_other_intent", datetime.now()), ), ], ) @@ -101,8 +102,8 @@ def test_event_has_proper_implementation(one_event, another_event): FollowupAction("my_action"), BotUttered("my_text", {"my_data": 1}), AgentUttered("my_text", "my_data"), - ReminderScheduled("my_action", datetime.now()), - ReminderScheduled("my_action", datetime.now(pytz.timezone("US/Central"))), + ReminderScheduled("my_intent", datetime.now()), + ReminderScheduled("my_intent", datetime.now(pytz.timezone("US/Central"))), ], ) def test_dict_serialisation(one_event): @@ -183,22 +184,49 @@ def test_json_parse_reminder(): # fmt: off # DOCS MARKER ReminderScheduled evt = { - "event": "reminder", - "action": "my_action", - "date_time": "2018-09-03T11:41:10.128172", - "name": "my_reminder", - "kill_on_user_msg": True, + "event": "reminder", + "intent": "my_intent", + "entities": {"entity1": "value1", "entity2": "value2"}, + "date_time": "2018-09-03T11:41:10.128172", + "name": "my_reminder", + "kill_on_user_msg": True, } # DOCS END # fmt: on assert Event.from_parameters(evt) == ReminderScheduled( - "my_action", + "my_intent", parser.parse("2018-09-03T11:41:10.128172"), name="my_reminder", kill_on_user_message=True, ) +def test_json_parse_reminder_cancelled(): + # fmt: off + # DOCS MARKER ReminderCancelled + evt = { + "event": "cancel_reminder", + "name": "my_reminder", + "intent": "my_intent", + "entities": [ + {"entity": "entity1", "value": "value1"}, + {"entity": "entity2", "value": "value2"}, + ], + "date_time": "2018-09-03T11:41:10.128172", + } + # DOCS END + # fmt: on + assert Event.from_parameters(evt) == ReminderCancelled( + name="my_reminder", + intent="my_intent", + entities=[ + {"entity": "entity1", "value": "value1"}, + {"entity": "entity2", "value": "value2"}, + ], + timestamp=parser.parse("2018-09-03T11:41:10.128172"), + ) + + def test_json_parse_undo(): # DOCS MARKER ActionReverted evt = {"event": "undo"} diff --git a/tests/core/test_processor.py b/tests/core/test_processor.py index 470e080b6b3e..7d5b94bb44a6 100644 --- a/tests/core/test_processor.py +++ b/tests/core/test_processor.py @@ -1,5 +1,4 @@ import asyncio -import logging import datetime import pytest @@ -8,7 +7,7 @@ import json from _pytest.monkeypatch import MonkeyPatch from aioresponses import aioresponses -from typing import Optional, Text +from typing import Optional, Text, List from unittest.mock import patch from rasa.core import jobs @@ -35,6 +34,10 @@ from rasa.utils.endpoints import EndpointConfig from tests.utilities import latest_request +from rasa.core.constants import EXTERNAL_MESSAGE_PREFIX, IS_EXTERNAL + +import logging + logger = logging.getLogger(__name__) @@ -152,11 +155,11 @@ async def test_reminder_scheduled( ): sender_id = uuid.uuid4().hex - reminder = ReminderScheduled("utter_greet", datetime.datetime.now()) + reminder = ReminderScheduled("remind", datetime.datetime.now()) tracker = default_processor.tracker_store.get_or_create_tracker(sender_id) tracker.update(UserUttered("test")) - tracker.update(ActionExecuted("action_reminder_reminder")) + tracker.update(ActionExecuted("action_schedule_reminder")) tracker.update(reminder) default_processor.tracker_store.save(tracker) @@ -168,18 +171,11 @@ async def test_reminder_scheduled( # retrieve the updated tracker t = default_processor.tracker_store.retrieve(sender_id) - assert t.events[-4] == UserUttered(None) - assert t.events[-3] == ActionExecuted("utter_greet") - assert t.events[-2] == BotUttered( - "hey there None!", - { - "elements": None, - "buttons": None, - "quick_replies": None, - "attachment": None, - "image": None, - "custom": None, - }, + assert t.events[-5] == UserUttered("test") + assert t.events[-4] == ActionExecuted("action_schedule_reminder") + assert isinstance(t.events[-3], ReminderScheduled) + assert t.events[-2] == UserUttered( + f"{EXTERNAL_MESSAGE_PREFIX}remind", intent={"name": "remind", IS_EXTERNAL: True} ) assert t.events[-1] == ActionExecuted("action_listen") @@ -207,7 +203,22 @@ async def test_reminder_aborted( assert len(t.events) == 3 # nothing should have been executed -async def test_reminder_cancelled( +async def wait_until_all_jobs_were_executed( + timeout_after_seconds: Optional[float] = None, +) -> None: + total_seconds = 0.0 + while len((await jobs.scheduler()).get_jobs()) > 0 and ( + not timeout_after_seconds or total_seconds < timeout_after_seconds + ): + await asyncio.sleep(0.1) + total_seconds += 0.1 + + if total_seconds >= timeout_after_seconds: + jobs.kill_scheduler() + raise TimeoutError + + +async def test_reminder_cancelled_multi_user( default_channel: CollectingOutputChannel, default_processor: MessageProcessor ): sender_ids = [uuid.uuid4().hex, uuid.uuid4().hex] @@ -219,13 +230,13 @@ async def test_reminder_cancelled( tracker.update(ActionExecuted("action_reminder_reminder")) tracker.update( ReminderScheduled( - "utter_greet", datetime.datetime.now(), kill_on_user_message=True + "greet", datetime.datetime.now(), kill_on_user_message=True ) ) trackers.append(tracker) - # cancel reminder for the first user - trackers[0].update(ReminderCancelled("utter_greet")) + # cancel all reminders (one) for the first user + trackers[0].update(ReminderCancelled()) for tracker in trackers: default_processor.tracker_store.save(tracker) @@ -241,15 +252,145 @@ async def test_reminder_cancelled( assert len((await jobs.scheduler()).get_jobs()) == 1 # execute the jobs - await asyncio.sleep(5) + await wait_until_all_jobs_were_executed(timeout_after_seconds=5.0) tracker_0 = default_processor.tracker_store.retrieve(sender_ids[0]) # there should be no utter_greet action - assert ActionExecuted("utter_greet") not in tracker_0.events + assert ( + UserUttered( + f"{EXTERNAL_MESSAGE_PREFIX}greet", + intent={"name": "greet", IS_EXTERNAL: True}, + ) + not in tracker_0.events + ) tracker_1 = default_processor.tracker_store.retrieve(sender_ids[1]) # there should be utter_greet action - assert ActionExecuted("utter_greet") in tracker_1.events + assert ( + UserUttered( + f"{EXTERNAL_MESSAGE_PREFIX}greet", + intent={"name": "greet", IS_EXTERNAL: True}, + ) + in tracker_1.events + ) + + +async def test_reminder_cancelled_cancels_job_with_name( + default_channel: CollectingOutputChannel, default_processor: MessageProcessor +): + sender_id = "][]][xy,,=+2f'[:/;>] <0d]A[e_,02" + + reminder = ReminderScheduled( + intent="greet", trigger_date_time=datetime.datetime.now() + ) + job_name = reminder.scheduled_job_name(sender_id) + reminder_cancelled = ReminderCancelled() + + assert reminder_cancelled.cancels_job_with_name(job_name, sender_id) + assert not reminder_cancelled.cancels_job_with_name(job_name.upper(), sender_id) + + +async def test_reminder_cancelled_cancels_job_with_name_special_name( + default_channel: CollectingOutputChannel, default_processor: MessageProcessor +): + sender_id = "][]][xy,,=+2f'[:/; >]<0d]A[e_,02" + name = "wkjbgr,34(,*&%^^&*(OP#LKMN V#NF# # #R" + + reminder = ReminderScheduled( + intent="greet", trigger_date_time=datetime.datetime.now(), name=name + ) + job_name = reminder.scheduled_job_name(sender_id) + reminder_cancelled = ReminderCancelled(name) + + assert reminder_cancelled.cancels_job_with_name(job_name, sender_id) + assert not reminder_cancelled.cancels_job_with_name(job_name.upper(), sender_id) + + +async def cancel_reminder_and_check( + tracker: DialogueStateTracker, + default_processor: MessageProcessor, + reminder_canceled_event: ReminderCancelled, + num_jobs_before: int, + num_jobs_after: int, +) -> None: + # cancel the sixth reminder + tracker.update(reminder_canceled_event) + + # check that the jobs were added + assert len((await jobs.scheduler()).get_jobs()) == num_jobs_before + + await default_processor._cancel_reminders(tracker.events, tracker) + + # check that only one job was removed + assert len((await jobs.scheduler()).get_jobs()) == num_jobs_after + + +async def test_reminder_cancelled_by_name( + default_channel: CollectingOutputChannel, + default_processor: MessageProcessor, + tracker_with_six_scheduled_reminders: DialogueStateTracker, +): + tracker = tracker_with_six_scheduled_reminders + await default_processor._schedule_reminders( + tracker.events, tracker, default_channel, default_processor.nlg + ) + + # cancel the sixth reminder + await cancel_reminder_and_check( + tracker, default_processor, ReminderCancelled("special"), 6, 5 + ) + + +async def test_reminder_cancelled_by_entities( + default_channel: CollectingOutputChannel, + default_processor: MessageProcessor, + tracker_with_six_scheduled_reminders: DialogueStateTracker, +): + tracker = tracker_with_six_scheduled_reminders + await default_processor._schedule_reminders( + tracker.events, tracker, default_channel, default_processor.nlg + ) + + # cancel the fourth reminder + await cancel_reminder_and_check( + tracker, + default_processor, + ReminderCancelled(entities=[{"entity": "name", "value": "Bruce Wayne"}]), + 6, + 5, + ) + + +async def test_reminder_cancelled_by_intent( + default_channel: CollectingOutputChannel, + default_processor: MessageProcessor, + tracker_with_six_scheduled_reminders: DialogueStateTracker, +): + tracker = tracker_with_six_scheduled_reminders + await default_processor._schedule_reminders( + tracker.events, tracker, default_channel, default_processor.nlg + ) + + # cancel the third, fifth, and sixth reminder + await cancel_reminder_and_check( + tracker, default_processor, ReminderCancelled(intent="default"), 6, 3 + ) + + +async def test_reminder_cancelled_all( + default_channel: CollectingOutputChannel, + default_processor: MessageProcessor, + tracker_with_six_scheduled_reminders: DialogueStateTracker, +): + tracker = tracker_with_six_scheduled_reminders + await default_processor._schedule_reminders( + tracker.events, tracker, default_channel, default_processor.nlg + ) + + # cancel all reminders + await cancel_reminder_and_check( + tracker, default_processor, ReminderCancelled(), 6, 0 + ) async def test_reminder_restart( diff --git a/tests/test_server.py b/tests/test_server.py index 6a3214393d49..ed43a620e942 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -706,6 +706,7 @@ def test_list_routes(default_agent: Agent): "replace_events", "retrieve_story", "execute_action", + "trigger_intent", "predict", "add_message", "train", @@ -851,6 +852,40 @@ def test_execute_with_not_existing_action(rasa_app: SanicTestClient): assert response.status == 500 +def test_trigger_intent(rasa_app: SanicTestClient): + data = {"name": "greet"} + _, response = rasa_app.post("/conversations/test_trigger/trigger_intent", json=data) + + assert response.status == 200 + + parsed_content = response.json + assert parsed_content["tracker"] + assert parsed_content["messages"] + + +def test_trigger_intent_with_missing_intent_name(rasa_app: SanicTestClient): + test_sender = "test_trigger_intent_with_missing_action_name" + + data = {"wrong-key": "greet"} + _, response = rasa_app.post( + f"/conversations/{test_sender}/trigger_intent", json=data + ) + + assert response.status == 400 + + +def test_trigger_intent_with_not_existing_intent(rasa_app: SanicTestClient): + test_sender = "test_trigger_intent_with_not_existing_intent" + _create_tracker_for_sender(rasa_app, test_sender) + + data = {"name": "ka[pa[opi[opj[oj[oija"} + _, response = rasa_app.post( + f"/conversations/{test_sender}/trigger_intent", json=data + ) + + assert response.status == 404 + + @pytest.mark.parametrize( "input_channels, output_channel_to_use, expected_channel", [