"""
The :class:`Agent` base class is the entry point of your project. You will
subclass it when defining your own Agent, and will later :meth:`Agent.register`
Intent classes and other resources into it.
Once the Agent is defined, you will connect it to a cloud service with a
:class:`Connector`, to make prediction and trigger requests.
"""
import re
import logging
import inspect
from types import ModuleType
from typing import List, Dict, Union
from dataclasses import dataclass
from intents.model.intent import Intent, _IntentMetaclass
from intents.model.entity import EntityMixin, SystemEntityMixin, _EntityMetaclass
from intents.model.context import Context, _ContextMetaclass
from intents.model.event import _EventMetaclass
from intents import language
logger = logging.getLogger(__name__)
class _AgentMetaclass(type):
languages: List[language.LanguageCode] = None
def __new__(cls, name, bases, dct):
result_cls = super().__new__(cls, name, bases, dct)
if name == 'Agent':
assert not bases
return result_cls
if not result_cls.languages:
result_cls.languages = language.agent_supported_languages(result_cls)
languages = []
for lan in result_cls.languages:
if isinstance(lan, language.LanguageCode):
languages.append(lan)
elif isinstance(lan, str):
languages.append(language.LanguageCode(lan))
else:
raise ValueError(f"Unsupported language '{lan}' for Agent '{result_cls}'. Must be a value of 'intents.LanguageCode'")
result_cls.languages = languages
return result_cls
[docs]class Agent(metaclass=_AgentMetaclass):
"""
As the name suggests, Agent is the base class that models an Agent
definition within the *Intents* framework.
Typically, you will define a single Agent class in your project, that could
be as simple as this:
.. code-block:: python
from intents import Agent
class MyAgent(Agent):
\"\"\"A little docstring for your Agent\"\"\"
You can optionally define the languages that you intend to support.
*Intents* will look for language resources based on the `language` class
variable:
.. code-block:: python
class MyAgent(Agent):
\"\"\"A little docstring for your Agent\"\"\"
languages = ["en", "it"]
Languages are values from :class:`intents.language.LanguageCode`. If omitted,
*Intents* will discover language resources by itself.
You won't do much more with your *Agent* class, other than registering
intents and resources with :meth:`Agent.register`, or passing it to a
:class:`Connector` to make predictions.
"""
languages: List[language.LanguageCode] = None
intents: List[Intent] = None
_intents_by_name: Dict[str, Intent] = None
_intents_by_event: Dict[str, Intent] = None
_entities_by_name: Dict[str, _EntityMetaclass] = None
_contexts_by_name: Dict[str, _ContextMetaclass] = None
_events_by_name: Dict[str, _EventMetaclass] = None
[docs] @classmethod
def register(cls, resource: Union[_IntentMetaclass, ModuleType]):
"""
Register the given resource in Agent. The resource could be:
* An :class:`Intent`
* A module. In this case, the module is scanned (non recursively) for
Intents, and each Intent is added individually
This is how you register a **single intent**:
.. code-block:: python
from intents import Agent, Intent
class MyAgent(Agent):
pass
@dataclass
class my_test_intent(Intent):
\"\"\"A little docstring for my Intent...\"\"\"
a_parameter: str
another_parameter: str
MyAgent.register(my_test_intent)
Alternatively, you can register a **whole module** containing Intents.
This is how you register all the intents that are defined in the
`smalltalk` module of `example_agent`:
.. code-block:: python
from example_agent import smalltalk
class MyAgent(Agent):
pass
MyAgent.register(smalltalk)
Note that together with each Intent, the Agent will register all of its
linked resources, such as Entities, Events and Contexts.
:param resource: The resource to register (an Intent, or a module
containing Intents)
"""
if isinstance(resource, _IntentMetaclass):
cls._register_intent(resource)
elif isinstance(resource, ModuleType):
for member_name, member in inspect.getmembers(resource, inspect.isclass):
if member.__module__ == resource.__name__ and issubclass(member, Intent):
cls._register_intent(member)
@classmethod
def _register_intent(cls, intent_cls: _IntentMetaclass):
"""
Register a single intent in the Agent class and check that language data is
present for all supported languages (examples and responses).
"""
if not cls.intents or not cls._intents_by_name and not cls._intents_by_event:
assert not cls.intents and not cls._intents_by_name and not cls._intents_by_event
assert not cls._contexts_by_name and not cls._events_by_name
cls.intents = []
cls._intents_by_name = {}
cls._intents_by_event = {}
cls._entities_by_name = {}
cls._contexts_by_name = {}
cls._events_by_name = {}
if cls._intents_by_name.get(intent_cls.name):
raise ValueError(f"Another intent exists with name {intent_cls.name}: {cls._intents_by_name[intent_cls.name]}")
# TODO: check conflicting events
# event_name = Agent._event_name(name)
# if conflicting_intent := cls._intents_by_event.get(event_name):
# raise ValueError(f"Intent name {name} is ambiguous and clashes with {conflicting_intent} ('{conflicting_intent.metadata.name}')")
language.intent_language_data(cls, intent_cls) # TODO: Agent languages only
for context_cls in intent_cls.input_contexts:
cls._register_context(context_cls)
for context in intent_cls.output_contexts:
cls._register_context(context)
for event_cls in intent_cls.events:
cls._register_event(event_cls, intent_cls)
for param_name, param_metadata in intent_cls.parameter_schema().items():
cls._register_entity(param_metadata.entity_cls, param_name, intent_cls.name)
cls.intents.append(intent_cls)
cls._intents_by_name[intent_cls.name] = intent_cls
cls._intents_by_event[intent_cls.events[0].name] = intent_cls
@classmethod
def _register_entity(cls, entity_cls: _EntityMetaclass, parameter_name: str, intent_name: str):
if not issubclass(entity_cls, EntityMixin):
raise ValueError(f"Invalid type '{entity_cls}' for parameter '{parameter_name}' in Intent '{intent_name}': must be an Entity. Try system entities such as 'intents.Sys.Integer', or define your own custom entity.")
if issubclass(entity_cls, SystemEntityMixin):
return
existing_cls = cls._entities_by_name.get(entity_cls.name)
if not existing_cls:
language.entity_language_data(cls, entity_cls) # Checks that language data is existing and consistent
cls._entities_by_name[entity_cls.name] = entity_cls
return
if id(entity_cls) != id(existing_cls):
existing_cls_path = f"{existing_cls.__module__}.{existing_cls.__qualname__}"
entity_cls_path = f"{entity_cls.__module__}.{entity_cls.__qualname__}"
raise ValueError(f"Two different Entity classes exist with the same name: '{existing_cls_path}' and '{entity_cls_path}'")
@classmethod
def _register_context(cls, context_obj_or_cls: Union[_ContextMetaclass, Context]):
if isinstance(context_obj_or_cls, Context):
context_cls = context_obj_or_cls.__class__
elif inspect.isclass(context_obj_or_cls) and issubclass(context_obj_or_cls, Context):
context_cls = context_obj_or_cls
else:
raise ValueError(f"Context {context_obj_or_cls} is not a Context instance or subclass")
existing_cls = cls._contexts_by_name.get(context_cls.name)
if not existing_cls:
cls._contexts_by_name[context_cls.name] = context_cls
return
if id(context_cls) != id(existing_cls):
existing_cls_path = f"{existing_cls.__module__}.{existing_cls.__qualname__}"
context_cls_path = f"{context_cls.__module__}.{context_cls.__qualname__}"
raise ValueError(f"Two different Context classes exist with the same name: '{existing_cls_path}' and '{context_cls_path}'")
@classmethod
def _register_event(cls, event_cls: _EventMetaclass, intent_cls: _IntentMetaclass):
existing_cls = cls._events_by_name.get(event_cls.name)
if not existing_cls:
cls._events_by_name[event_cls.name] = event_cls
cls._intents_by_event[event_cls.name] = intent_cls
return
if id(existing_cls) != id(event_cls):
existing_cls_path = f"{existing_cls.__module__}.{existing_cls.__qualname__}"
event_cls_path = f"{event_cls.__module__}.{event_cls.__qualname__}"
raise ValueError(f"Two different Event classes exist with the same name: '{existing_cls_path}' and '{event_cls_path}'")
# TODO: model different intents with same event and different input
# context (ok) vs. different intents with same event and same input
# context (not ok).
existing_intent = cls._intents_by_event[event_cls.name]
raise ValueError(f"Event '{event_cls.name}' is alreadt associated to Intent '{existing_intent}'. An Event can only be associated with 1 intent. (differenciation by input contexts is not supported yet)")
# def save_session(self):
# """
# Store the current session (most importantly, the list of active
# contexts) to a persisted storage.
# """
# raise NotImplementedError("Context persistence is unsupported yet")
# def load_session(self):
# """
# Load session information (most importantly, the list of active contexts),
# in a format that can be used by :meth:`Agent.predict` to restore the
# state before prediction.
# """
# raise NotImplementedError("Context persistence is unsupported yet")
def _pylint_hack(self):
raise NotImplementedError()
@staticmethod
def _event_name(intent_name: str) -> str:
"""
Generate the default event name that we associate with every intent.
>>> _event_name('test.intent_name')
'E_TEST_INTENT_NAME'
TODO: This is only used in Dialogflow -> Deprecate and move to DialogflowConnector
"""
return "E_" + intent_name.upper().replace('.', '_')