Skip to content

Commit

Permalink
Merge pull request #4870 from RasaHQ/tracker-sessions
Browse files Browse the repository at this point in the history
Tracker sessions
  • Loading branch information
ricwo authored Dec 16, 2019
2 parents b052678 + 5df314e commit 7f8e3e4
Show file tree
Hide file tree
Showing 42 changed files with 1,510 additions and 234 deletions.
25 changes: 25 additions & 0 deletions changelog/4830.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
Added conversation sessions to trackers.

A conversation session represents the dialog between the assistant and a user.
Conversation sessions can begin in three ways: 1. the user begins the conversation
with the assistant, 2. the user sends their first message after a configurable period
of inactivity, or 3. a manual session start is triggered with the ``/session_start``
intent message. The period of inactivity after which a new conversation session is
triggered is defined in the domain using the ``session_expiration_time`` key in the
``session_config`` section. The introduction of conversation sessions comprises the
following changes:

- Added a new event ``SessionStarted`` that marks the beginning of a new conversation
session.
- Added a new default action ``ActionSessionStart``. This action takes all
``SlotSet`` events from the previous session and applies it to the next session.
- Added a new default intent ``session_start`` which triggers the start of a new
conversation session.
- ``SQLTrackerStore`` and ``MongoTrackerStore`` only retrieve
events from the last session from the database.


.. note::

The session behaviour is disabled for existing projects, i.e. existing domains
without session config section.
4 changes: 4 additions & 0 deletions data/test_domains/duplicate_intents.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,7 @@ actions:
- utter_default
- utter_greet
- utter_goodbye

session_config:
session_expiration_time: 60
carry_over_slots_to_new_session: true
22 changes: 22 additions & 0 deletions docs/api/events.rst
Original file line number Diff line number Diff line change
Expand Up @@ -271,3 +271,25 @@ Log an executed action
.. literalinclude:: ../../rasa/core/events/__init__.py
:dedent: 4
:pyobject: ActionExecuted.apply_to

Start a new conversation session
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

:Short: Marks the beginning of a new conversation session. Resets the tracker and
triggers an ``ActionSessionStart`` which by default applies the existing
``SlotSet`` events to the new session.

:JSON:
.. literalinclude:: ../../tests/core/test_events.py
:start-after: # DOCS MARKER SessionStarted
:dedent: 4
:end-before: # DOCS END
:Class:
.. autoclass:: rasa.core.events.SessionStarted

:Effect:
When added to a tracker, this is the code used to update the tracker:

.. literalinclude:: ../../rasa/core/events/__init__.py
:dedent: 4
:pyobject: SessionStarted.apply_to
61 changes: 61 additions & 0 deletions docs/api/rasa-sdk.rst
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,67 @@ Details of the ``dispatcher.utter_message()`` method:

.. automethod:: rasa_sdk.executor.CollectingDispatcher.utter_message


.. _custom_session_start:

Customising the session start action
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

The default behaviour of the session start action is to take all existing slots and to
carry them over into the next session. Let's say you do not want to carry over all
slots, but only a user's name and their phone number. To do that, you'd override the
``action_session_start`` with a custom action that might look like this:

.. testcode::

from typing import Text, List, Dict, Any

from rasa_sdk import Action, Tracker
from rasa_sdk.events import SlotSet, SessionStarted, ActionExecuted, EventType
from rasa_sdk.executor import CollectingDispatcher


class ActionSessionStart(Action):
def name(self) -> Text:
return "action_session_start"

@staticmethod
def fetch_slots(tracker: Tracker) -> List[EventType]:
"""Collect slots that contain the user's name and phone number."""

slots = []

for key in ("name", "phone_number"):
value = tracker.get_slot(key)
if value is not None:
slots.append(SlotSet(key=key, value=value))

return slots

async def run(
self,
dispatcher: CollectingDispatcher,
tracker: Tracker,
domain: Dict[Text, Any],
) -> List[EventType]:

# the session should begin with a `session_started` event
events = [SessionStarted()]

# any slots that should be carried over should come after the
# `session_started` event
events.extend(self.fetch_slots(tracker))

# an `action_listen` should be added at the end as a user message follows
events.append(ActionExecuted("action_listen"))

return events

.. note::

You need to explicitly add ``action_session_start`` to your domain to override this
custom action.

Events
------

Expand Down
8 changes: 7 additions & 1 deletion docs/api/tracker.rst
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
:desc: Trackers mantain the state of the a dialogue and can be
:desc: Trackers maintain the state of the a dialogue and can be
featurized for machine learning algorithms right out of
the box.

Expand All @@ -7,6 +7,12 @@
Tracker
=======

.. edit-link::

Trackers maintain the state of a dialogue between the assistant and the user in the form
of conversation sessions. To learn more about how to configure the session behaviour,
check out the docs on :ref:`session_config`.

.. edit-link::
:url: https://github.com/RasaHQ/rasa/edit/master/rasa/core/trackers.py
:text: SUGGEST DOCSTRING EDITS
Expand Down
15 changes: 13 additions & 2 deletions docs/core/actions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ using the :ref:`callbackInput` channel to send messages to a webhook.
Default Actions
---------------

There are eight default actions:
The available default actions are:

+-----------------------------------+------------------------------------------------+
| ``action_listen`` | Stop predicting more actions and wait for user |
Expand All @@ -148,6 +148,17 @@ There are eight default actions:
| | if the :ref:`mapping-policy` is included in |
| | the policy configuration. |
+-----------------------------------+------------------------------------------------+
| ``action_session_start`` | Start a new conversation session. Take all set |
| | slots, mark the beginning of a new conversation|
| | session and re-apply the existing ``SlotSet`` |
| | events. This action is triggered automatically |
| | after an inactivity period defined by the |
| | ``session_expiration_time`` parameter in the |
| | domain's :ref:`session_config`. Can be |
| | triggered manually during a conversation by |
| | entering ``/session_start``. All conversations |
| | begin with an ``action_session_start``. |
+-----------------------------------+------------------------------------------------+
| ``action_default_fallback`` | Undo the last user message (as if the user did |
| | not send it and the bot did not react) and |
| | utter a message that the bot did not |
Expand Down Expand Up @@ -175,7 +186,7 @@ There are eight default actions:
| | included in the policy configuration. |
+-----------------------------------+------------------------------------------------+

All the default actions can be overwritten. To do so, add the action name
All the default actions can be overridden. To do so, add the action name
to the list of actions in your domain:

.. code-block:: yaml
Expand Down
41 changes: 41 additions & 0 deletions docs/core/domains.rst
Original file line number Diff line number Diff line change
Expand Up @@ -316,3 +316,44 @@ featurized as normal.

If you really want these entities not to influence action prediction we
suggest you make the slots with the same name of type ``unfeaturized``.

.. _session_config:

Session configuration
---------------------

A conversation session represents the dialogue between the assistant and the user.
Conversation sessions can begin in three ways:

1. the user begins the conversation with the assistant,
2. the user sends their first message after a configurable period of inactivity, or
3. a manual session start is triggered with the ``/session_start`` intent message.

You can define the period of inactivity after which a new conversation
session is triggered in the domain under the ``session_config`` key.
``session_expiration_time`` defines the time of inactivity in minutes after which a
new session will begin. ``carry_over_slots_to_new_session`` determines whether
existing set slots should be carried over to new sessions.

The default session configuration looks as follows:

.. code-block:: yaml
session_config:
session_expiration_time: 60 # value in minutes, 0 means infinitely long
carry_over_slots_to_new_session: true # set to false to forget slots between sessions
This means that if a user sends their first message after 60 minutes of inactivity, a
new conversation session is triggered, and that any existing slots are carried over
into the new session. Setting the value of ``session_expiration_time`` to 0 means
that sessions will not end (note that the ``action_session_start`` action will still
be triggered at the very beginning of conversations).

.. note::

A session start triggers the default action ``action_session_start``. Its default
implementation moves all existing slots into the new session. Note that all
conversations begin with an ``action_session_start``. Overriding this action could
for instance be used to initialise the tracker with slots from an external API
call, or to start the conversation with a bot message. The docs on
:ref:`custom_session_start` shows you how to do that.
4 changes: 4 additions & 0 deletions rasa/cli/initial_project/domain.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,7 @@ templates:

utter_iamabot:
- text: "I am a bot, powered by Rasa."

session_config:
session_expiration_time: 60
carry_over_slots_to_new_session: true
5 changes: 2 additions & 3 deletions rasa/cli/x.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@
import asyncio
import importlib.util
import logging
import warnings
import os
import signal
import traceback
from multiprocessing import get_context
from typing import List, Text, Optional, Tuple, Union, Iterable
from typing import List, Text, Optional, Tuple, Iterable

import aiohttp
import ruamel.yaml as yaml
Expand Down Expand Up @@ -328,7 +327,7 @@ def rasa_x(args: argparse.Namespace):
async def _pull_runtime_config_from_server(
config_endpoint: Optional[Text],
attempts: int = 60,
wait_time_between_pulls: Union[int, float] = 5,
wait_time_between_pulls: float = 5,
keys: Iterable[Text] = ("endpoints", "credentials"),
) -> Optional[List[Text]]:
"""Pull runtime config from `config_endpoint`.
Expand Down
3 changes: 3 additions & 0 deletions rasa/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,6 @@
DEFAULT_SANIC_WORKERS = 1
ENV_SANIC_WORKERS = "SANIC_WORKERS"
ENV_SANIC_BACKLOG = "SANIC_BACKLOG"

DEFAULT_SESSION_EXPIRATION_TIME_IN_MINUTES = 60
DEFAULT_CARRY_OVER_SLOTS_TO_NEW_SESSION = True
49 changes: 48 additions & 1 deletion rasa/core/actions/action.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import json
import logging
import typing
from typing import List, Text, Optional, Dict, Any
from typing import List, Text, Optional, Dict, Any, Generator

import aiohttp

Expand Down Expand Up @@ -37,13 +37,16 @@
from rasa.core.domain import Domain
from rasa.core.nlg import NaturalLanguageGenerator
from rasa.core.channels.channel import OutputChannel
from rasa.core.events import SlotSet

logger = logging.getLogger(__name__)

ACTION_LISTEN_NAME = "action_listen"

ACTION_RESTART_NAME = "action_restart"

ACTION_SESSION_START_NAME = "action_session_start"

ACTION_DEFAULT_FALLBACK_NAME = "action_default_fallback"

ACTION_DEACTIVATE_FORM_NAME = "action_deactivate_form"
Expand All @@ -62,6 +65,7 @@ def default_actions() -> List["Action"]:
return [
ActionListen(),
ActionRestart(),
ActionSessionStart(),
ActionDefaultFallback(),
ActionDeactivateForm(),
ActionRevertFallbackEvents(),
Expand Down Expand Up @@ -331,6 +335,49 @@ async def run(
return evts + [Restarted()]


class ActionSessionStart(Action):
"""Applies a conversation session start.
Takes all `SlotSet` events from the previous session and applies them to the new
session.
"""

def name(self) -> Text:
return ACTION_SESSION_START_NAME

@staticmethod
def _slot_set_events_from_tracker(
tracker: "DialogueStateTracker",
) -> List["SlotSet"]:
"""Fetch SlotSet events from tracker and carry over key, value and metadata."""

from rasa.core.events import SlotSet

return [
SlotSet(key=event.key, value=event.value, metadata=event.metadata)
for event in tracker.applied_events()
if isinstance(event, SlotSet)
]

async def run(
self,
output_channel: "OutputChannel",
nlg: "NaturalLanguageGenerator",
tracker: "DialogueStateTracker",
domain: "Domain",
) -> List[Event]:
from rasa.core.events import SessionStarted

_events = [SessionStarted()]

if domain.session_config.carry_over_slots:
_events.extend(self._slot_set_events_from_tracker(tracker))

_events.append(ActionExecuted(ACTION_LISTEN_NAME))

return _events


class ActionDefaultFallback(ActionUtterTemplate):
"""Executes the fallback action and goes back to the previous state
of the dialogue"""
Expand Down
6 changes: 4 additions & 2 deletions rasa/core/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,11 +486,13 @@ def noop(_):
return await processor.handle_message(message)

# noinspection PyUnusedLocal
def predict_next(self, sender_id: Text, **kwargs: Any) -> Optional[Dict[Text, Any]]:
async def predict_next(
self, sender_id: Text, **kwargs: Any
) -> Optional[Dict[Text, Any]]:
"""Handle a single message."""

processor = self.create_processor()
return processor.predict_next(sender_id)
return await processor.predict_next(sender_id)

# noinspection PyUnusedLocal
async def log_message(
Expand Down
6 changes: 3 additions & 3 deletions rasa/core/brokers/pika.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def initialise_pika_connection(
password: Text,
port: Union[Text, int] = 5672,
connection_attempts: int = 20,
retry_delay_in_seconds: Union[int, float] = 5,
retry_delay_in_seconds: float = 5,
) -> "BlockingConnection":
"""Create a Pika `BlockingConnection`.
Expand Down Expand Up @@ -61,7 +61,7 @@ def _get_pika_parameters(
password: Text,
port: Union[Text, int] = 5672,
connection_attempts: int = 20,
retry_delay_in_seconds: Union[int, float] = 5,
retry_delay_in_seconds: float = 5,
) -> "Parameters":
"""Create Pika `Parameters`.
Expand Down Expand Up @@ -136,7 +136,7 @@ def initialise_pika_channel(
password: Text,
port: Union[Text, int] = 5672,
connection_attempts: int = 20,
retry_delay_in_seconds: Union[int, float] = 5,
retry_delay_in_seconds: float = 5,
) -> "BlockingChannel":
"""Initialise a Pika channel with a durable queue.
Expand Down
Loading

0 comments on commit 7f8e3e4

Please sign in to comment.