diff --git a/ovos_workshop/intents.py b/ovos_workshop/intents.py index 15730e4c..6355f935 100644 --- a/ovos_workshop/intents.py +++ b/ovos_workshop/intents.py @@ -1,29 +1,31 @@ +import abc +import itertools from os.path import exists from threading import RLock from typing import List, Tuple, Optional -import abc + from ovos_bus_client.message import Message, dig_for_message from ovos_bus_client.util import get_mycroft_bus from ovos_utils.log import LOG, log_deprecation -try: - # backwards compat isinstancechecks - from adapt.intent import IntentBuilder as _IB, Intent as _I -except ImportError: - # adapt is optional - _I = object - _IB = object - class _IntentMeta(abc.ABCMeta): def __instancecheck__(self, instance): - return isinstance(instance, _I) or \ - super().__instancecheck__(instance) - - -class Intent(_I, metaclass=_IntentMeta): - def __init__(self, name="", requires=None, at_least_one=None, optional=None): + check = super().__instancecheck__(instance) + if not check: + try: + # backwards compat isinstancechecks + from adapt.intent import Intent as _I + check = isinstance(instance, _I) + except ImportError: + pass + return check + + +class Intent(metaclass=_IntentMeta): + def __init__(self, name="", requires=None, at_least_one=None, optional=None, excludes=None): """Create Intent object + Args: name(str): Name for Intent requires(list): Entities that are required @@ -34,52 +36,187 @@ def __init__(self, name="", requires=None, at_least_one=None, optional=None): self.requires = requires or [] self.at_least_one = at_least_one or [] self.optional = optional or [] + self.excludes = excludes or [] def validate(self, tags, confidence): """Using this method removes tags from the result of validate_with_tags + Returns: intent(intent): Results from validate_with_tags """ - if _I is not object: - return super().validate(tags, confidence) - raise NotImplementedError("please install adapt-parser") + intent, tags = self.validate_with_tags(tags, confidence) + return intent def validate_with_tags(self, tags, confidence): """Validate whether tags has required entites for this intent to fire + Args: tags(list): Tags and Entities used for validation confidence(float): The weight associate to the parse result, as indicated by the parser. This is influenced by a parser that uses edit distance or context. + Returns: intent, tags: Returns intent and tags used by the intent on failure to meat required entities then returns intent with confidence of 0.0 and an empty list for tags. """ - if _I is not object: - return super().validate_with_tags(tags, confidence) - raise NotImplementedError("please install adapt-parser") + result = {'intent_type': self.name} + intent_confidence = 0.0 + local_tags = tags[:] + used_tags = [] + + # Check excludes first + for exclude_type in self.excludes: + exclude_tag, _canonical_form, _tag_confidence = \ + self._find_first_tag(local_tags, exclude_type) + if exclude_tag: + result['confidence'] = 0.0 + return result, [] + + for require_type, attribute_name in self.requires: + required_tag, canonical_form, tag_confidence = \ + self._find_first_tag(local_tags, require_type) + if not required_tag: + result['confidence'] = 0.0 + return result, [] + + result[attribute_name] = canonical_form + if required_tag in local_tags: + local_tags.remove(required_tag) + used_tags.append(required_tag) + intent_confidence += tag_confidence + + if len(self.at_least_one) > 0: + best_resolution = self._resolve_one_of(local_tags, self.at_least_one) + if not best_resolution: + result['confidence'] = 0.0 + return result, [] + else: + for key in best_resolution: + # TODO: at least one should support aliases + result[key] = best_resolution[key][0].get('key') + intent_confidence += 1.0 * best_resolution[key][0]['entities'][0].get('confidence', 1.0) + used_tags.append(best_resolution[key][0]) + if best_resolution in local_tags: + local_tags.remove(best_resolution[key][0]) + + for optional_type, attribute_name in self.optional: + optional_tag, canonical_form, tag_confidence = \ + self._find_first_tag(local_tags, optional_type) + if not optional_tag or attribute_name in result: + continue + result[attribute_name] = canonical_form + if optional_tag in local_tags: + local_tags.remove(optional_tag) + used_tags.append(optional_tag) + intent_confidence += tag_confidence + + total_confidence = (intent_confidence / len(tags) * confidence) \ + if tags else 0.0 + + CLIENT_ENTITY_NAME = 'Client' # TODO - ??? what is this magic string + + target_client, canonical_form, confidence = \ + self._find_first_tag(local_tags, CLIENT_ENTITY_NAME) + + result['target'] = target_client.get('key') if target_client else None + result['confidence'] = total_confidence + + return result, used_tags + + @classmethod + def _resolve_one_of(cls, tags, at_least_one): + """Search through all combinations of at_least_one rules to find a + combination that is covered by tags + Args: + tags(list): List of tags with Entities to search for Entities + at_least_one(list): List of Entities to find in tags -class _IntentBuilderMeta(abc.ABCMeta): - def __instancecheck__(self, instance): - return isinstance(instance, _IB) or \ - super().__instancecheck__(instance) + Returns: + object: + returns None if no match is found but returns any match as an object + """ + for possible_resolution in itertools.product(*at_least_one): + resolution = {} + pr = possible_resolution[:] + for entity_type in pr: + last_end_index = -1 + if entity_type in resolution: + last_end_index = resolution[entity_type][-1].get('end_token') + tag, value, c = cls._find_first_tag(tags, entity_type, + after_index=last_end_index) + if not tag: + break + else: + if entity_type not in resolution: + resolution[entity_type] = [] + resolution[entity_type].append(tag) + # Check if this is a valid resolution (all one_of rules matched) + if len(resolution) == len(possible_resolution): + return resolution + + return None + + @staticmethod + def _find_first_tag(tags, entity_type, after_index=-1): + """Searches tags for entity type after given index + + Args: + tags(list): a list of tags with entity types to be compared to + entity_type + entity_type(str): This is he entity type to be looking for in tags + after_index(int): the start token must be greater than this. + Returns: + ( tag, v, confidence ): + tag(str): is the tag that matched + v(str): ? the word that matched? + confidence(float): is a measure of accuracy. 1 is full confidence + and 0 is none. + """ + for tag in tags: + for entity in tag.get('entities'): + for v, t in entity.get('data'): + if t.lower() == entity_type.lower() and \ + (tag.get('start_token', 0) > after_index or \ + tag.get('from_context', False)): + return tag, v, entity.get('confidence') + + return None, None, None -class IntentBuilder(_IB, metaclass=_IntentBuilderMeta): + +class _IntentBuilderMeta(abc.ABCMeta): + def __instancecheck__(self, instance): + check = super().__instancecheck__(instance) + if not check: + try: + # backwards compat isinstancechecks + from adapt.intent import IntentBuilder as _IB + check = isinstance(instance, _IB) + except ImportError: + pass + return check + + +class IntentBuilder(metaclass=_IntentBuilderMeta): """ IntentBuilder, used to construct intent parsers. + Attributes: at_least_one(list): A list of Entities where one is required. These are separated into lists so you can have one of (A or B) and then require one of (D or F). requires(list): A list of Required Entities optional(list): A list of optional Entities + excludes(list): A list of forbidden Entities name(str): Name of intent + Notes: This is designed to allow construction of intents in one line. + Example: IntentBuilder("Intent")\ .requires("A")\ @@ -90,12 +227,14 @@ class IntentBuilder(_IB, metaclass=_IntentBuilderMeta): def __init__(self, intent_name): """ Constructor + Args: intent_name(str): the name of the intents that this parser parses/validates """ self.at_least_one = [] self.requires = [] + self.excludes = [] self.optional = [] self.name = intent_name @@ -103,8 +242,10 @@ def one_of(self, *args): """ The intent parser should require one of the provided entity types to validate this clause. + Args: args(args): *args notation list of entity names + Returns: self: to continue modifications. """ @@ -114,10 +255,12 @@ def one_of(self, *args): def require(self, entity_type, attribute_name=None): """ The intent parser should require an entity of the provided type. + Args: entity_type(str): an entity type attribute_name(str): the name of the attribute on the parsed intent. Defaults to match entity_type. + Returns: self: to continue modifications. """ @@ -126,14 +269,29 @@ def require(self, entity_type, attribute_name=None): self.requires += [(entity_type, attribute_name)] return self + def exclude(self, entity_type): + """ + The intent parser must not contain an entity of the provided type. + + Args: + entity_type(str): an entity type + + Returns: + self: to continue modifications. + """ + self.excludes.append(entity_type) + return self + def optionally(self, entity_type, attribute_name=None): """ Parsed intents from this parser can optionally include an entity of the provided type. + Args: entity_type(str): an entity type attribute_name(str): the name of the attribute on the parsed intent. Defaults to match entity_type. + Returns: self: to continue modifications. """ @@ -145,10 +303,12 @@ def optionally(self, entity_type, attribute_name=None): def build(self): """ Constructs an intent from the builder's specifications. + :return: an Intent instance. """ return Intent(self.name, self.requires, - self.at_least_one, self.optional) + self.at_least_one, self.optional, + self.excludes) def to_alnum(skill_id: str) -> str: @@ -512,10 +672,5 @@ def open_intent_envelope(message): return Intent(intent_dict.get('name'), intent_dict.get('requires'), intent_dict.get('at_least_one'), - intent_dict.get('optional')) - - -if __name__ == "__main__": - i1 = _I("a", [], [], []) # skills using adapt directly - assert isinstance(i1, Intent) # backwards compat via metaclass - + intent_dict.get('optional'), + intent_dict.get('excludes')) diff --git a/test/unittests/skills/test_mycroft_skill/test_mycroft_skill.py b/test/unittests/skills/test_mycroft_skill/test_mycroft_skill.py index b2b4f18f..08cfdb4b 100644 --- a/test/unittests/skills/test_mycroft_skill/test_mycroft_skill.py +++ b/test/unittests/skills/test_mycroft_skill/test_mycroft_skill.py @@ -109,6 +109,7 @@ def test_register_intent(self): s._startup(self.emitter, "A") expected = [{'at_least_one': [], 'name': 'A:a', + 'excludes': [], 'optional': [], 'requires': [('AKeyword', 'AKeyword')]}] msg_data = self.emitter.get_results() @@ -121,6 +122,7 @@ def test_register_intent(self): expected = [{'at_least_one': [], 'name': 'A:a', 'optional': [], + 'excludes': [], 'requires': [('AKeyword', 'AKeyword')]}] msg_data = self.emitter.get_results() @@ -142,6 +144,7 @@ def test_enable_disable_intent(self): expected = [{'at_least_one': [], 'name': 'A:a', 'optional': [], + 'excludes': [], 'requires': [('AKeyword', 'AKeyword')]}] msg_data = self.emitter.get_results() self.assertTrue(expected[0] in msg_data) @@ -161,6 +164,7 @@ def test_enable_disable_intent_handlers(self): expected = [{'at_least_one': [], 'name': 'A:a', 'optional': [], + 'excludes': [], 'requires': [('AKeyword', 'AKeyword')]}] msg_data = self.emitter.get_results() self.assertTrue(expected[0] in msg_data) @@ -259,6 +263,7 @@ def test_register_decorators(self): expected = [{'at_least_one': [], 'name': 'A:a', 'optional': [], + 'excludes': [], 'requires': [('AKeyword', 'AKeyword')]}, { 'file_name': join(dirname(__file__), 'intent_file', diff --git a/test/unittests/test_intent.py b/test/unittests/test_intent.py new file mode 100644 index 00000000..8e2cf97f --- /dev/null +++ b/test/unittests/test_intent.py @@ -0,0 +1,271 @@ +import unittest + +from ovos_workshop.intents import Intent, IntentBuilder + + +class IntentTest(unittest.TestCase): + + def test_basic_intent(self): + intent = IntentBuilder("play television intent") \ + .require("PlayVerb") \ + .require("Television Show") \ + .build() + tags = [{'match': 'play', 'key': 'play', 'start_token': 0, + 'entities': [{'key': 'play', 'match': 'play', 'data': [('play', 'PlayVerb')], 'confidence': 1.0}], + 'end_token': 0, 'from_context': False}, {'start_token': 1, 'entities': [ + {'key': 'the big bang theory', 'match': 'the big bang theory', + 'data': [('the big bang theory', 'Television Show')], 'confidence': 1.0}], 'confidence': 1.0, + 'end_token': 4, 'match': 'the big bang theory', + 'key': 'the big bang theory', 'from_context': False}] + result_intent = intent.validate(tags, 0.95) + assert result_intent.get('confidence') > 0.0 + assert result_intent.get('PlayVerb') == 'play' + assert result_intent.get('Television Show') == "the big bang theory" + + def test_at_least_one(self): + intent = IntentBuilder("play intent") \ + .require("PlayVerb") \ + .one_of("Television Show", "Radio Station") \ + .build() + tags = [{'match': 'play', 'key': 'play', 'start_token': 0, + 'entities': [{'key': 'play', 'match': 'play', 'data': [('play', 'PlayVerb')], 'confidence': 1.0}], + 'end_token': 0, 'from_context': False}, {'start_token': 1, 'entities': [ + {'key': 'the big bang theory', 'match': 'the big bang theory', + 'data': [('the big bang theory', 'Television Show')], 'confidence': 1.0}], 'confidence': 1.0, + 'end_token': 4, 'match': 'the big bang theory', + 'key': 'the big bang theory', 'from_context': False}] + + result_intent = intent.validate(tags, 0.95) + assert result_intent.get('confidence') > 0.0 + assert result_intent.get('PlayVerb') == 'play' + assert result_intent.get('Television Show') == "the big bang theory" + + tags = [{'match': 'play', 'key': 'play', 'start_token': 0, + 'entities': [{'key': 'play', 'match': 'play', 'data': [('play', 'PlayVerb')], 'confidence': 1.0}], + 'end_token': 0, 'from_context': False}, + {'match': 'barenaked ladies', 'key': 'barenaked ladies', 'start_token': 2, 'entities': [ + {'key': 'barenaked ladies', 'match': 'barenaked ladies', + 'data': [('barenaked ladies', 'Radio Station')], 'confidence': 1.0}], 'end_token': 3, + 'from_context': False}] + + result_intent = intent.validate(tags, 0.8) + assert result_intent.get('confidence') > 0.0 + assert result_intent.get('PlayVerb') == 'play' + assert result_intent.get('Radio Station') == "barenaked ladies" + + def test_at_least_on_no_required(self): + intent = IntentBuilder("play intent") \ + .one_of("Television Show", "Radio Station") \ + .build() + tags = [{'match': 'play', 'key': 'play', 'start_token': 0, + 'entities': [{'key': 'play', 'match': 'play', 'data': [('play', 'PlayVerb')], 'confidence': 1.0}], + 'end_token': 0, 'from_context': False}, {'start_token': 1, 'entities': [ + {'key': 'the big bang theory', 'match': 'the big bang theory', + 'data': [('the big bang theory', 'Television Show')], 'confidence': 1.0}], 'confidence': 1.0, + 'end_token': 4, 'match': 'the big bang theory', + 'key': 'the big bang theory', 'from_context': False}] + result_intent = intent.validate(tags, 0.9) + assert result_intent.get('confidence') > 0.0 + assert result_intent.get('Television Show') == "the big bang theory" + + tags = [{'match': 'play', 'key': 'play', 'start_token': 0, + 'entities': [{'key': 'play', 'match': 'play', 'data': [('play', 'PlayVerb')], 'confidence': 1.0}], + 'end_token': 0, 'from_context': False}, + {'match': 'barenaked ladies', 'key': 'barenaked ladies', 'start_token': 2, 'entities': [ + {'key': 'barenaked ladies', 'match': 'barenaked ladies', + 'data': [('barenaked ladies', 'Radio Station')], 'confidence': 1.0}], 'end_token': 3, + 'from_context': False}] + + result_intent = intent.validate(tags, 0.8) + assert result_intent.get('confidence') > 0.0 + assert result_intent.get('Radio Station') == "barenaked ladies" + + def test_at_least_one_alone(self): + intent = IntentBuilder("OptionsForLunch") \ + .one_of("Question", "Command") \ + .build() + tags = [{'match': 'show', 'key': 'show', 'start_token': 0, + 'entities': [{'key': 'show', 'match': 'show', 'data': [('show', 'Command')], 'confidence': 1.0}], + 'end_token': 0, 'from_context': False}] + + result_intent = intent.validate(tags, 1.0) + assert result_intent.get('confidence') > 0.0 + assert result_intent.get('Command') == "show" + + def test_basic_intent_with_alternate_names(self): + intent = IntentBuilder("play television intent") \ + .require("PlayVerb", "Play Verb") \ + .require("Television Show", "series") \ + .build() + tags = [{'match': 'play', 'key': 'play', 'start_token': 0, + 'entities': [{'key': 'play', 'match': 'play', 'data': [('play', 'PlayVerb')], 'confidence': 1.0}], + 'end_token': 0, 'from_context': False}, {'start_token': 1, 'entities': [ + {'key': 'the big bang theory', 'match': 'the big bang theory', + 'data': [('the big bang theory', 'Television Show')], 'confidence': 1.0}], 'confidence': 1.0, + 'end_token': 4, 'match': 'the big bang theory', + 'key': 'the big bang theory', 'from_context': False}] + + result_intent = intent.validate(tags, 0.95) + assert result_intent.get('confidence') > 0.0 + assert result_intent.get('Play Verb') == 'play' + assert result_intent.get('series') == "the big bang theory" + + def test_resolve_one_of(self): + tags = [ + { + "confidence": 1.0, + "end_token": 1, + "entities": [ + { + "confidence": 1.0, + "data": [ + [ + "what is", + "skill_iot_controlINFORMATION_QUERY" + ] + ], + "key": "what is", + "match": "what is" + } + ], + "from_context": False, + "key": "what is", + "match": "what is", + "start_token": 0 + }, + { + "end_token": 3, + "entities": [ + { + "confidence": 1.0, + "data": [ + [ + "temperature", + "skill_weatherTemperature" + ], + [ + "temperature", + "skill_iot_controlTEMPERATURE" + ] + ], + "key": "temperature", + "match": "temperature" + } + ], + "from_context": False, + "key": "temperature", + "match": "temperature", + "start_token": 3 + }, + { + "confidence": 1.0, + "end_token": 7, + "entities": [ + { + "confidence": 1.0, + "data": [ + [ + "living room", + "skill_iot_controlENTITY" + ] + ], + "key": "living room", + "match": "living room" + } + ], + "from_context": False, + "key": "living room", + "match": "living room", + "start_token": 6 + } + ] + + at_least_one = [ + [ + "skill_iot_controlINFORMATION_QUERY" + ], + [ + "skill_iot_controlTEMPERATURE", + "skill_iot_controlENTITY" + ], + [ + "skill_iot_controlTEMPERATURE" + ] + ] + + result = { + "skill_iot_controlENTITY": [ + { + "confidence": 1.0, + "end_token": 7, + "entities": [ + { + "confidence": 1.0, + "data": [ + [ + "living room", + "skill_iot_controlENTITY" + ] + ], + "key": "living room", + "match": "living room" + } + ], + "from_context": False, + "key": "living room", + "match": "living room", + "start_token": 6 + } + ], + "skill_iot_controlINFORMATION_QUERY": [ + { + "confidence": 1.0, + "end_token": 1, + "entities": [ + { + "confidence": 1.0, + "data": [ + [ + "what is", + "skill_iot_controlINFORMATION_QUERY" + ] + ], + "key": "what is", + "match": "what is" + } + ], + "from_context": False, + "key": "what is", + "match": "what is", + "start_token": 0 + } + ], + "skill_iot_controlTEMPERATURE": [ + { + "end_token": 3, + "entities": [ + { + "confidence": 1.0, + "data": [ + [ + "temperature", + "skill_weatherTemperature" + ], + [ + "temperature", + "skill_iot_controlTEMPERATURE" + ] + ], + "key": "temperature", + "match": "temperature" + } + ], + "from_context": False, + "key": "temperature", + "match": "temperature", + "start_token": 3 + } + ] + } + + assert Intent._resolve_one_of(tags, at_least_one) == result