rasa/shared/core/slots.py
import logging
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional, Text, Type
import rasa.shared.core.constants
from rasa.shared.exceptions import RasaException
import rasa.shared.utils.common
import rasa.shared.utils.io
from rasa.shared.constants import DOCS_URL_SLOTS
logger = logging.getLogger(__name__)
class InvalidSlotTypeException(RasaException):
"""Raised if a slot type is invalid."""
class InvalidSlotConfigError(RasaException, ValueError):
"""Raised if a slot's config is invalid."""
class Slot(ABC):
"""Key-value store for storing information during a conversation."""
@property
@abstractmethod
def type_name(self) -> Text:
"""Name of the type of slot."""
...
def __init__(
self,
name: Text,
mappings: List[Dict[Text, Any]],
initial_value: Any = None,
value_reset_delay: Optional[int] = None,
influence_conversation: bool = True,
) -> None:
"""Create a Slot.
Args:
name: The name of the slot.
initial_value: The initial value of the slot.
mappings: List containing slot mappings.
value_reset_delay: After how many turns the slot should be reset to the
initial_value. This is behavior is currently not implemented.
influence_conversation: If `True` the slot will be featurized and hence
influence the predictions of the dialogue polices.
"""
self.name = name
self.mappings = mappings
self._value = initial_value
self.initial_value = initial_value
self._value_reset_delay = value_reset_delay
self.influence_conversation = influence_conversation
self._has_been_set = False
def feature_dimensionality(self) -> int:
"""How many features this single slot creates.
Returns:
The number of features. `0` if the slot is unfeaturized. The dimensionality
of the array returned by `as_feature` needs to correspond to this value.
"""
if not self.influence_conversation:
return 0
return self._feature_dimensionality()
def _feature_dimensionality(self) -> int:
"""See the docstring for `feature_dimensionality`."""
return 1
def has_features(self) -> bool:
"""Indicate if the slot creates any features."""
return self.feature_dimensionality() != 0
def value_reset_delay(self) -> Optional[int]:
"""After how many turns the slot should be reset to the initial_value.
If the delay is set to `None`, the slot will keep its value forever.
"""
# TODO: FUTURE this needs to be implemented - slots are not reset yet
return self._value_reset_delay
def as_feature(self) -> List[float]:
if not self.influence_conversation:
return []
return self._as_feature()
@abstractmethod
def _as_feature(self) -> List[float]:
raise NotImplementedError(
"Each slot type needs to specify how its "
"value can be converted to a feature. Slot "
"'{}' is a generic slot that can not be used "
"for predictions. Make sure you add this "
"slot to your domain definition, specifying "
"the type of the slot. If you implemented "
"a custom slot type class, make sure to "
"implement `.as_feature()`."
"".format(self.name)
)
def reset(self) -> None:
"""Resets the slot's value to the initial value."""
self.value = self.initial_value
self._has_been_set = False
@property
def value(self) -> Any:
"""Gets the slot's value."""
return self._value
@value.setter
def value(self, value: Any) -> None:
"""Sets the slot's value."""
self._value = value
self._has_been_set = True
@property
def has_been_set(self) -> bool:
"""Indicates if the slot's value has been set."""
return self._has_been_set
def __str__(self) -> Text:
return f"{self.__class__.__name__}({self.name}: {self.value})"
def __repr__(self) -> Text:
return f"<{self.__class__.__name__}({self.name}: {self.value})>"
@staticmethod
def resolve_by_type(type_name: Text) -> Type["Slot"]:
"""Returns a slots class by its type name."""
for cls in rasa.shared.utils.common.all_subclasses(Slot):
if cls.type_name == type_name:
return cls
try:
return rasa.shared.utils.common.class_from_module_path(type_name)
except (ImportError, AttributeError):
raise InvalidSlotTypeException(
f"Failed to find slot type, '{type_name}' is neither a known type nor "
f"user-defined. If you are creating your own slot type, make "
f"sure its module path is correct. "
f"You can find all build in types at {DOCS_URL_SLOTS}"
)
def persistence_info(self) -> Dict[str, Any]:
"""Returns relevant information to persist this slot."""
return {
"type": rasa.shared.utils.common.module_path_from_instance(self),
"initial_value": self.initial_value,
"influence_conversation": self.influence_conversation,
"mappings": self.mappings,
}
def fingerprint(self) -> Text:
"""Returns a unique hash for the slot which is stable across python runs.
Returns:
fingerprint of the slot
"""
data = {"slot_name": self.name, "slot_value": self.value}
data.update(self.persistence_info())
return rasa.shared.utils.io.get_dictionary_fingerprint(data)
class FloatSlot(Slot):
"""A slot storing a float value."""
type_name = "float"
def __init__(
self,
name: Text,
mappings: List[Dict[Text, Any]],
initial_value: Optional[float] = None,
value_reset_delay: Optional[int] = None,
max_value: float = 1.0,
min_value: float = 0.0,
influence_conversation: bool = True,
) -> None:
"""Creates a FloatSlot.
Raises:
InvalidSlotConfigError, if the min-max range is invalid.
UserWarning, if initial_value is outside the min-max range.
"""
super().__init__(
name, mappings, initial_value, value_reset_delay, influence_conversation
)
self.max_value = max_value
self.min_value = min_value
if min_value >= max_value:
raise InvalidSlotConfigError(
"Float slot ('{}') created with an invalid range "
"using min ({}) and max ({}) values. Make sure "
"min is smaller than max."
"".format(self.name, self.min_value, self.max_value)
)
if initial_value is not None and not (min_value <= initial_value <= max_value):
rasa.shared.utils.io.raise_warning(
f"Float slot ('{self.name}') created with an initial value "
f"{self.value}. This value is outside of the configured min "
f"({self.min_value}) and max ({self.max_value}) values."
)
def _as_feature(self) -> List[float]:
try:
capped_value = max(self.min_value, min(self.max_value, float(self.value)))
if abs(self.max_value - self.min_value) > 0:
covered_range = abs(self.max_value - self.min_value)
else:
covered_range = 1
return [1.0, (capped_value - self.min_value) / covered_range]
except (TypeError, ValueError):
return [0.0, 0.0]
def persistence_info(self) -> Dict[Text, Any]:
"""Returns relevant information to persist this slot."""
d = super().persistence_info()
d["max_value"] = self.max_value
d["min_value"] = self.min_value
return d
def _feature_dimensionality(self) -> int:
return len(self.as_feature())
class BooleanSlot(Slot):
"""A slot storing a truth value."""
type_name = "bool"
def _as_feature(self) -> List[float]:
try:
if self.value is not None:
return [1.0, float(bool_from_any(self.value))]
else:
return [0.0, 0.0]
except (TypeError, ValueError):
# we couldn't convert the value to float - using default value
return [0.0, 0.0]
def _feature_dimensionality(self) -> int:
return len(self.as_feature())
def bool_from_any(x: Any) -> bool:
"""Converts bool/float/int/str to bool or raises error."""
if isinstance(x, bool):
return x
elif isinstance(x, (float, int)):
return x == 1.0
elif isinstance(x, str):
if x.isnumeric():
return float(x) == 1.0
elif x.strip().lower() == "true":
return True
elif x.strip().lower() == "false":
return False
else:
raise ValueError("Cannot convert string to bool")
else:
raise TypeError("Cannot convert to bool")
class TextSlot(Slot):
type_name = "text"
def _as_feature(self) -> List[float]:
return [1.0 if self.value is not None else 0.0]
class ListSlot(Slot):
type_name = "list"
def _as_feature(self) -> List[float]:
try:
if self.value is not None and len(self.value) > 0:
return [1.0]
else:
return [0.0]
except (TypeError, ValueError):
# we couldn't convert the value to a list - using default value
return [0.0]
# FIXME: https://github.com/python/mypy/issues/8085
@Slot.value.setter # type: ignore[attr-defined,misc]
def value(self, value: Any) -> None:
"""Sets the slot's value."""
if value and not isinstance(value, list):
# Make sure we always store list items
value = [value]
# Call property setter of superclass
# FIXME: https://github.com/python/mypy/issues/8085
super(ListSlot, self.__class__).value.fset(self, value) # type: ignore[attr-defined] # noqa: E501
class CategoricalSlot(Slot):
"""Slot type which can be used to branch conversations based on its value."""
type_name = "categorical"
def __init__(
self,
name: Text,
mappings: List[Dict[Text, Any]],
values: Optional[List[Any]] = None,
initial_value: Any = None,
value_reset_delay: Optional[int] = None,
influence_conversation: bool = True,
) -> None:
"""Creates a `Categorical Slot` (see parent class for detailed docstring)."""
super().__init__(
name, mappings, initial_value, value_reset_delay, influence_conversation
)
if values and None in values:
rasa.shared.utils.io.raise_warning(
f"Categorical slot '{self.name}' has `null` listed as a possible value"
f" in the domain file, which translates to `None` in Python. This value"
f" is reserved for when the slot is not set, and should not be listed"
f" as a value in the slot's definition."
f" Rasa will ignore `null` as a possible value for the '{self.name}'"
f" slot. Consider changing this value in your domain file to, for"
f" example, `unset`, or provide the value explicitly as a string by"
f' using quotation marks: "null".',
category=UserWarning,
)
self.values = (
[str(v).lower() for v in values if v is not None] if values else []
)
def add_default_value(self) -> None:
"""Adds the special default value to the list of possible values."""
values = set(self.values)
if rasa.shared.core.constants.DEFAULT_CATEGORICAL_SLOT_VALUE not in values:
self.values.append(
rasa.shared.core.constants.DEFAULT_CATEGORICAL_SLOT_VALUE
)
def persistence_info(self) -> Dict[Text, Any]:
"""Returns serialized slot."""
d = super().persistence_info()
d["values"] = [
value
for value in self.values
# Don't add default slot when persisting it.
# We'll re-add it on the fly when creating the domain.
if value != rasa.shared.core.constants.DEFAULT_CATEGORICAL_SLOT_VALUE
]
return d
def _as_feature(self) -> List[float]:
r = [0.0] * self.feature_dimensionality()
# Return the zero-filled array if the slot is unset (i.e. set to None).
# Conceptually, this is similar to the case when the featurisation process
# fails, hence the returned features here are the same as for that case.
if self.value is None:
return r
try:
for i, v in enumerate(self.values):
if v == str(self.value).lower():
r[i] = 1.0
break
else:
if (
rasa.shared.core.constants.DEFAULT_CATEGORICAL_SLOT_VALUE
in self.values
):
i = self.values.index(
rasa.shared.core.constants.DEFAULT_CATEGORICAL_SLOT_VALUE
)
r[i] = 1.0
else:
rasa.shared.utils.io.raise_warning(
f"Categorical slot '{self.name}' is set to a value "
f"('{self.value}') "
"that is not specified in the domain. "
"Value will be ignored and the slot will "
"behave as if no value is set. "
"Make sure to add all values a categorical "
"slot should store to the domain."
)
except (TypeError, ValueError):
logger.exception("Failed to featurize categorical slot.")
return r
return r
def _feature_dimensionality(self) -> int:
return len(self.values)
class AnySlot(Slot):
"""Slot which can be used to store any value.
Users need to create a subclass of `Slot` in case
the information is supposed to get featurized.
"""
type_name = "any"
def __init__(
self,
name: Text,
mappings: List[Dict[Text, Any]],
initial_value: Any = None,
value_reset_delay: Optional[int] = None,
influence_conversation: bool = False,
) -> None:
"""Creates an `Any Slot` (see parent class for detailed docstring).
Raises:
InvalidSlotConfigError, if slot is featurized.
"""
if influence_conversation:
raise InvalidSlotConfigError(
f"An {AnySlot.__name__} cannot be featurized. "
f"Please use a different slot type for slot '{name}' instead. If you "
f"need to featurize a data type which is not supported out of the box, "
f"implement a custom slot type by subclassing '{Slot.__name__}'. "
f"See the documentation for more information: {DOCS_URL_SLOTS}"
)
super().__init__(
name, mappings, initial_value, value_reset_delay, influence_conversation
)
def __eq__(self, other: Any) -> bool:
"""Compares object with other object."""
if not isinstance(other, AnySlot):
return NotImplemented
return (
self.name == other.name
and self.initial_value == other.initial_value
and self._value_reset_delay == other._value_reset_delay
and self.value == other.value
)
def _as_feature(self) -> List[float]:
raise InvalidSlotConfigError(
f"An {AnySlot.__name__} cannot be featurized. "
f"Please use a different slot type for slot '{self.name}' instead. If you "
f"need to featurize a data type which is not supported out of the box, "
f"implement a custom slot type by subclassing '{Slot.__name__}'. "
f"See the documentation for more information: {DOCS_URL_SLOTS}"
)