Source code for intents.model.relations

"""
The interpretation of an utterance may change depending on the context of the
conversation. Context-dependant dynamics in the conversation are modeled by the
**relations** intents have with each other. This module defines an API to
specify them.

.. note::

   Usage of relations is demonstrated in the :mod:`example_agent.shop` module of
   the Example Agent

Inheritance
===========
Since we define intents as Python classes, we can reuse of one of the founding
concepts of object oriented programming: **inheritance**.

.. code-block:: python

    @dataclass
    class AskCoffee(Intent):
        \"\"\"Hello, I'd like a light roast coffee\"\"\"
        roast_level: CoffeeRoastLevel = "medium"

    @dataclass
    class AskEspresso(AskCoffee):
        \"\"\"Hello, I'd like a dark roast espresso.\"\"\"

An intent should subclass another when its meaning **includes and specifies**
the meaning of the other intent. When an intent is a subclass of another:

    * It will inherit all **parameters**, types and default values of the parent intent
    * It will inherit all the other **relations** of the parent intent
    * It will inherit the parent's **fulfillment** method
    * It will be rendered as a separate, independent intent

      * It will **not** inherit language data such as example utterances and responses

Follow
======

The :func:`follow` relation is a context constraint: an intent that follows another
one can only be predicted after the followed one.

.. code-block:: python

    from intents import follow

    @dataclass
    class AddMilk(Intent):
        \"\"\"With milk please\"\"\"
        parent_ask_coffee: AskCoffee = follow()

An intent should follow another when it only makes sense in the parent's
context. In the example, it doesn't make sense to walk into a cafè and utter
"With milk please"; however, it does make sense to say so after asking for a
coffee.

Context parameters can be accessed with another OOP fundamental concept:
**composition**.

.. code-block:: python

    >>> prediction = connector.predict("I want a dark roast espresso")
    >>> prediction = connector.predict("With milk please")
    >>> prediction.intent.parent_ask_coffee.roast_level
    "dark"

This relation is implemented by looking at the **lifespan** of intents.
`AskCoffee` starts with a lifespan of 5 (this can be configured by setting a
`lifespan = N` property in the parent Intent class). This value is decremented
at each conversation turn; intents that follow `AskCoffee` can only be predicted
while its lifespan is > 0. :func:`follow` can also replice the current lifespan
of an Intent with a new one (e.g. set it to 0 to kill the context), see its
documentation for details.

It's worth noting that the *follow* relation is **inherited** by subclasses:

* If intent `AskEspresso` is a subclass of `AskCoffee`, and `AddMilk` follows
  `AskCoffe`, then `AddMilk` also follows `AskEspresso`.
* If intent `AskSkimmedMilk` is a subclass of `AskMilk`, and `AskMilk` follows
  `AskCoffee`, then `AskSkimmedMilk` also follows `AskCoffee` (and `AskEspresso`
  in the example above)

API
===
"""
from enum import Enum
from typing import List, Union, Type
import dataclasses
from dataclasses import dataclass, field, fields

from intents import Intent

[docs]class RelationType(Enum): """ Currently, the only available type is `RelationType.FOLLOW`. If you want to define a *follow* relation, use :func:`follow`. """ FOLLOW = "follow"
[docs]@dataclass class FollowRelationParameters: new_lifespan: int
# fallback: Type[Intent] # within: int
[docs]def follow(*, new_lifespan: int=None) -> dataclasses.Field: """ This can be used as a value for an Intent Relation field, e.g. .. code-block:: python from intents import follow @dataclass class AddMilk(Intent): \"\"\"With milk please\"\"\" parent_ask_coffee: AskCoffee = follow() This will make "AddMilk" predictable only if AskCoffe was predicted before, meaning, it is present in context with lifespan > 0. Lifespan is a counter that decreases from a given number (default is 5) at each conversation turn. :func:`follow` can update this number, either to keep the context alive, or to kill it when it's no more necessary: .. code-block:: python @dataclass class CancelAskCoffee(Intent): \"\"\"I don't want coffee anymore...\"\"\" parent_ask_coffee: AskCoffee = follow(new_lifespan=0) .. warning:: The returned field currently sets `default=None` as a workaround to some known limitations of dataclasses with inheritance. This behavior may be adjusted again before 1.0 Args: new_lifespan: Reset the lifespan of the followed intent to the given value """ # TODO: solve inheritance after default fields and remove default return field(default=None, metadata={ "relation_type": RelationType.FOLLOW, "relation_parameters": FollowRelationParameters( new_lifespan=new_lifespan ) })
[docs]@dataclass class IntentRelation: """ Represent an Intent Relation definition from a "source" Intent (the one that defines the relation) to a "target" Intent (the one that is referenced in *source*). For instance, given .. code-block:: python @dataclass class AddMilk(Intent): \"\"\"With milk please\"\"\" parent_ask_coffee: AskCoffee = follow() Then the `follow` relation defined in `AddMilk` can be represented as .. code-block:: python IntentRelation( relation_type=RelationType.FOLLOW relation_parameters=None, field_name="parent_ask_coffee", source_cls=AddMilk, target_cls=AskCoffee ) :class:`IntentRelation` objects are typically produced by :func:`intent_relations`, and enclosed in its returned :class:`IntentRelationMap` result structure. Args: relation_type: One of the relation types relation_parameters: Relation parameters as they are specified by the relation field field_name: Name of the field in `source_cls` that defines the relation source_cls: The Intent class where the relation is defined target_cls: The other Intent class that is referenced by the relation """ relation_type: RelationType relation_parameters: type field_name: str source_cls: Type[Intent] target_cls: Type[Intent]
[docs]@dataclass class FollowIntentRelation(IntentRelation): """ Args: relation_parameters: Relation parameters as they are specified by the relation field field_name: Name of the field in `source_cls` that defines the relation source_cls: The Intent class where the relation is defined target_cls: The other Intent class that is referenced by the relation """ relation_parameters: FollowRelationParameters relation_type: RelationType = field(default=RelationType.FOLLOW, init=None)
[docs]@dataclass class IntentRelationMap: """ A map of an Intent's relations, as it is produced by :func:`intent_relations`. Args: follow: A list of intents that are followed by the Relation subject """ follow: List[FollowIntentRelation] = field(default_factory=list)
[docs]def intent_relations(intent: Union[Intent, Type[Intent]]) -> IntentRelationMap: """ Produce a map of all the relations that are defined by the given Intent. Args: intent: The relation subject. Could be an Intent class or an Intent instance """ intent_cls = intent if isinstance(intent, type) else type(intent) result = IntentRelationMap() for cls_field in fields(intent): relation_type: RelationType = cls_field.metadata.get("relation_type") if relation_type: assert relation_type in [RelationType.FOLLOW] if relation_type == RelationType.FOLLOW: # TODO: check that there aren't other equivalent "follow" relations # (i.e. with same class or superclasses) in same intent relation_parameters: FollowRelationParameters = cls_field.metadata.get("relation_parameters") result.follow.append(FollowIntentRelation( relation_parameters=relation_parameters, field_name=cls_field.name, source_cls=intent_cls, target_cls=cls_field.type )) return result