From 1f7076cb5c3b1ff17e76947c080a6072b13124c5 Mon Sep 17 00:00:00 2001 From: jarbasal Date: Thu, 30 Sep 2021 00:21:18 +0100 Subject: [PATCH 1/5] intent utils --- ovos_utils/__init__.py | 96 +--- ovos_utils/bracket_expansion.py | 196 +++++++ ovos_utils/events.py | 80 ++- ovos_utils/file_utils.py | 237 +++++++++ ovos_utils/intents/__init__.py | 421 +-------------- ovos_utils/intents/converse.py | 201 +++++++ .../intents/intent_service_interface.py | 495 ++++++++++++++++++ 7 files changed, 1213 insertions(+), 513 deletions(-) create mode 100644 ovos_utils/bracket_expansion.py create mode 100644 ovos_utils/file_utils.py create mode 100644 ovos_utils/intents/converse.py create mode 100644 ovos_utils/intents/intent_service_interface.py diff --git a/ovos_utils/__init__.py b/ovos_utils/__init__.py index d8b9134..38c66c7 100644 --- a/ovos_utils/__init__.py +++ b/ovos_utils/__init__.py @@ -12,14 +12,14 @@ # from threading import Thread from time import sleep -import os -from os.path import isdir, join, dirname +from os.path import isdir, join import re import datetime import kthread from ovos_utils.network_utils import * from inflection import camelize, titleize, transliterate, parameterize, \ ordinalize +from ovos_utils.file_utils import resolve_ovos_resource_file, resolve_resource_file def ensure_mycroft_import(): @@ -35,33 +35,6 @@ def ensure_mycroft_import(): raise -def resolve_ovos_resource_file(res_name): - """Convert a resource into an absolute filename. - used internally for ovos resources - """ - # First look for fully qualified file (e.g. a user setting) - if os.path.isfile(res_name): - return res_name - - # now look in bundled ovos resources - filename = join(dirname(__file__), "res", res_name) - if os.path.isfile(filename): - return filename - - # let's look in ovos_workshop if it's installed - try: - import ovos_workshop - pkg_dir = dirname(ovos_workshop.__file__) - filename = join(pkg_dir, "res", res_name) - if os.path.isfile(filename): - return filename - filename = join(pkg_dir, "res", "ui", res_name) - if os.path.isfile(filename): - return filename - except: - pass - return None # Resource cannot be resolved - def get_mycroft_root(): paths = [ @@ -75,71 +48,6 @@ def get_mycroft_root(): return None -def resolve_resource_file(res_name, root_path=None, config=None): - """Convert a resource into an absolute filename. - - Resource names are in the form: 'filename.ext' - or 'path/filename.ext' - - The system wil look for ~/.mycroft/res_name first, and - if not found will look at /opt/mycroft/res_name, - then finally it will look for res_name in the 'mycroft/res' - folder of the source code package. - - Example: - With mycroft running as the user 'bob', if you called - resolve_resource_file('snd/beep.wav') - it would return either '/home/bob/.mycroft/snd/beep.wav' or - '/opt/mycroft/snd/beep.wav' or '.../mycroft/res/snd/beep.wav', - where the '...' is replaced by the path where the package has - been installed. - - Args: - res_name (str): a resource path/name - config (dict): mycroft.conf, to read data directory from - Returns: - str: path to resource or None if no resource found - """ - if config is None: - from ovos_utils.configuration import read_mycroft_config - config = read_mycroft_config() - - # First look for fully qualified file (e.g. a user setting) - if os.path.isfile(res_name): - return res_name - - # Now look for ~/.mycroft/res_name (in user folder) - filename = os.path.expanduser("~/.mycroft/" + res_name) - if os.path.isfile(filename): - return filename - - # Next look for /opt/mycroft/res/res_name - data_dir = os.path.expanduser(config.get('data_dir', "/opt/mycroft")) - filename = os.path.expanduser(os.path.join(data_dir, res_name)) - if os.path.isfile(filename): - return filename - - # look in ovos_utils package itself - found = resolve_ovos_resource_file(res_name) - if found: - return found - - # Finally look for it in the source package - paths = [ - "/opt/venvs/mycroft-core/lib/python3.7/site-packages/", # mark1/2 - "/opt/venvs/mycroft-core/lib/python3.4/site-packages/ ", # old mark1 installs - "/home/pi/mycroft-core" # picroft - ] - if root_path: - paths += [root_path] - for p in paths: - filename = os.path.join(p, 'mycroft', 'res', res_name) - filename = os.path.abspath(os.path.normpath(filename)) - if os.path.isfile(filename): - return filename - - return None # Resource cannot be resolved - def create_killable_daemon(target, args=(), kwargs=None, autostart=True): """Helper to quickly create and start a thread with daemon = True""" diff --git a/ovos_utils/bracket_expansion.py b/ovos_utils/bracket_expansion.py new file mode 100644 index 0000000..1c69a6f --- /dev/null +++ b/ovos_utils/bracket_expansion.py @@ -0,0 +1,196 @@ +import re + + +def expand_parentheses(sent): + """ + ['1', '(', '2', '|', '3, ')'] -> [['1', '2'], ['1', '3']] + For example: + Will it (rain|pour) (today|tomorrow|)? + ----> + Will it rain today? + Will it rain tomorrow? + Will it rain? + Will it pour today? + Will it pour tomorrow? + Will it pour? + Args: + sent (list): List of tokens in sentence + Returns: + list>: Multiple possible sentences from original + """ + return SentenceTreeParser(sent).expand_parentheses() + + +def expand_options(parentheses_line: str) -> list: + """ + Convert 'test (a|b)' -> ['test a', 'test b'] + Args: + parentheses_line: Input line to expand + Returns: + List of expanded possibilities + """ + # 'a(this|that)b' -> [['a', 'this', 'b'], ['a', 'that', 'b']] + options = expand_parentheses(re.split(r'([(|)])', parentheses_line)) + return [re.sub(r'\s+', ' ', ' '.join(i)).strip() for i in options] + + +class Fragment: + """(Abstract) empty sentence fragment""" + + def __init__(self, tree): + """ + Construct a sentence tree fragment which is merely a wrapper for + a list of Strings + Args: + tree (?): Base tree for the sentence fragment, type depends on + subclass, refer to those subclasses + """ + self._tree = tree + + def tree(self): + """Return the represented sentence tree as raw data.""" + return self._tree + + def expand(self): + """ + Expanded version of the fragment. In this case an empty sentence. + Returns: + List>: A list with an empty sentence (= token/string list) + """ + return [[]] + + def __str__(self): + return self._tree.__str__() + + def __repr__(self): + return self._tree.__repr__() + + +class Word(Fragment): + """ + Single word in the sentence tree. + Construct with a string as argument. + """ + + def expand(self): + """ + Creates one sentence that contains exactly that word. + Returns: + List>: A list with the given string as sentence + (= token/string list) + """ + return [[self._tree]] + + +class Sentence(Fragment): + """ + A Sentence made of several concatenations/words. + Construct with a List as argument. + """ + + def expand(self): + """ + Creates a combination of all sub-sentences. + Returns: + List>: A list with all subsentence expansions combined in + every possible way + """ + old_expanded = [[]] + for sub in self._tree: + sub_expanded = sub.expand() + new_expanded = [] + while len(old_expanded) > 0: + sentence = old_expanded.pop() + for new in sub_expanded: + new_expanded.append(sentence + new) + old_expanded = new_expanded + return old_expanded + + +class Options(Fragment): + """ + A Combination of possible sub-sentences. + Construct with List as argument. + """ + + def expand(self): + """ + Returns all of its options as seperated sub-sentences. + Returns: + List>: A list containing the sentences created by all + expansions of its sub-sentences + """ + options = [] + for option in self._tree: + options.extend(option.expand()) + return options + + +class SentenceTreeParser: + """ + Generate sentence token trees from a list of tokens + ['1', '(', '2', '|', '3, ')'] -> [['1', '2'], ['1', '3']] + """ + + def __init__(self, tokens): + self.tokens = tokens + + def _parse(self): + """ + Generate sentence token trees + ['1', '(', '2', '|', '3, ')'] -> ['1', ['2', '3']] + """ + self._current_position = 0 + return self._parse_expr() + + def _parse_expr(self): + """ + Generate sentence token trees from the current position to + the next closing parentheses / end of the list and return it + ['1', '(', '2', '|', '3, ')'] -> ['1', [['2'], ['3']]] + ['2', '|', '3'] -> [['2'], ['3']] + """ + # List of all generated sentences + sentence_list = [] + # Currently active sentence + cur_sentence = [] + sentence_list.append(Sentence(cur_sentence)) + # Determine which form the current expression has + while self._current_position < len(self.tokens): + cur = self.tokens[self._current_position] + self._current_position += 1 + if cur == '(': + # Parse the subexpression + subexpr = self._parse_expr() + # Check if the subexpression only has one branch + # -> If so, append "(" and ")" and add it as is + normal_brackets = False + if len(subexpr.tree()) == 1: + normal_brackets = True + cur_sentence.append(Word('(')) + # add it to the sentence + cur_sentence.append(subexpr) + if normal_brackets: + cur_sentence.append(Word(')')) + elif cur == '|': + # Begin parsing a new sentence + cur_sentence = [] + sentence_list.append(Sentence(cur_sentence)) + elif cur == ')': + # End parsing the current subexpression + break + # TODO anything special about {sth}? + else: + cur_sentence.append(Word(cur)) + return Options(sentence_list) + + def _expand_tree(self, tree): + """ + Expand a list of sub sentences to all combinated sentences. + ['1', ['2', '3']] -> [['1', '2'], ['1', '3']] + """ + return tree.expand() + + def expand_parentheses(self): + tree = self._parse() + return self._expand_tree(tree) diff --git a/ovos_utils/events.py b/ovos_utils/events.py index fddd2a5..a8b6421 100644 --- a/ovos_utils/events.py +++ b/ovos_utils/events.py @@ -1,9 +1,81 @@ -from ovos_utils.log import LOG -from ovos_utils.messagebus import Message, FakeBus import time from datetime import datetime, timedelta from inspect import signature +from ovos_utils.intents.intent_service_interface import to_alnum +from ovos_utils.log import LOG +from ovos_utils.messagebus import Message, FakeBus + + +def unmunge_message(message, skill_id): + """Restore message keywords by removing the Letterified skill ID. + Args: + message (Message): Intent result message + skill_id (str): skill identifier + Returns: + Message without clear keywords + """ + if isinstance(message, Message) and isinstance(message.data, dict): + skill_id = to_alnum(skill_id) + for key in list(message.data.keys()): + if key.startswith(skill_id): + # replace the munged key with the real one + new_key = key[len(skill_id):] + message.data[new_key] = message.data.pop(key) + + return message + + +def get_handler_name(handler): + """Name (including class if available) of handler function. + + Args: + handler (function): Function to be named + + Returns: + string: handler name as string + """ + if '__self__' in dir(handler) and 'name' in dir(handler.__self__): + return handler.__self__.name + '.' + handler.__name__ + else: + return handler.__name__ + + +def create_wrapper(handler, skill_id, on_start, on_end, on_error): + """Create the default skill handler wrapper. + + This wrapper handles things like metrics, reporting handler start/stop + and errors. + handler (callable): method/function to call + skill_id: skill_id for associated skill + on_start (function): function to call before executing the handler + on_end (function): function to call after executing the handler + on_error (function): function to call for error reporting + """ + + def wrapper(message): + try: + message = unmunge_message(message, skill_id) + if on_start: + on_start(message) + + if len(signature(handler).parameters) == 0: + handler() + else: + handler(message) + + except Exception as e: + if on_error: + if len(signature(on_error).parameters) == 2: + on_error(e, message) + else: + on_error(e) + finally: + if on_end: + on_end(message) + + return wrapper + def create_basic_wrapper(handler, on_error=None): """Create the default skill handler wrapper. @@ -18,6 +90,7 @@ def create_basic_wrapper(handler, on_error=None): Returns: Wrapped callable """ + def wrapper(message): try: if len(signature(handler).parameters) == 0: @@ -37,6 +110,7 @@ class EventContainer: This container tracks events added by a skill, allowing unregistering all events on shutdown. """ + def __init__(self, bus=None): self.bus = bus or FakeBus() self.events = [] @@ -53,6 +127,7 @@ def add(self, name, handler, once=False): once (bool, optional): Event handler will be removed after it has been run once. """ + def once_wrapper(message): # Remove registered one-time handler before invoking, # allowing them to re-schedule themselves. @@ -113,6 +188,7 @@ def clear(self): class EventSchedulerInterface: """Interface for accessing the event scheduler over the message bus.""" + def __init__(self, name, sched_id=None, bus=None): self.name = name self.sched_id = sched_id diff --git a/ovos_utils/file_utils.py b/ovos_utils/file_utils.py new file mode 100644 index 0000000..de9122a --- /dev/null +++ b/ovos_utils/file_utils.py @@ -0,0 +1,237 @@ +import collections +import csv +import re +import os +from os import walk +from os.path import splitext, join, dirname + +from ovos_utils.bracket_expansion import expand_options +from ovos_utils.intents.intent_service_interface import to_alnum, munge_regex +from ovos_utils.log import LOG + + +def resolve_ovos_resource_file(res_name): + """Convert a resource into an absolute filename. + used internally for ovos resources + """ + # First look for fully qualified file (e.g. a user setting) + if os.path.isfile(res_name): + return res_name + + # now look in bundled ovos resources + filename = join(dirname(__file__), "res", res_name) + if os.path.isfile(filename): + return filename + + # let's look in ovos_workshop if it's installed + try: + import ovos_workshop + pkg_dir = dirname(ovos_workshop.__file__) + filename = join(pkg_dir, "res", res_name) + if os.path.isfile(filename): + return filename + filename = join(pkg_dir, "res", "ui", res_name) + if os.path.isfile(filename): + return filename + except: + pass + return None # Resource cannot be resolved + + +def resolve_resource_file(res_name, root_path=None, config=None): + """Convert a resource into an absolute filename. + + Resource names are in the form: 'filename.ext' + or 'path/filename.ext' + + The system wil look for ~/.mycroft/res_name first, and + if not found will look at /opt/mycroft/res_name, + then finally it will look for res_name in the 'mycroft/res' + folder of the source code package. + + Example: + With mycroft running as the user 'bob', if you called + resolve_resource_file('snd/beep.wav') + it would return either '/home/bob/.mycroft/snd/beep.wav' or + '/opt/mycroft/snd/beep.wav' or '.../mycroft/res/snd/beep.wav', + where the '...' is replaced by the path where the package has + been installed. + + Args: + res_name (str): a resource path/name + config (dict): mycroft.conf, to read data directory from + Returns: + str: path to resource or None if no resource found + """ + if config is None: + from ovos_utils.configuration import read_mycroft_config + config = read_mycroft_config() + + # First look for fully qualified file (e.g. a user setting) + if os.path.isfile(res_name): + return res_name + + # Now look for ~/.mycroft/res_name (in user folder) + filename = os.path.expanduser("~/.mycroft/" + res_name) + if os.path.isfile(filename): + return filename + + # Next look for /opt/mycroft/res/res_name + data_dir = os.path.expanduser(config.get('data_dir', "/opt/mycroft")) + filename = os.path.expanduser(os.path.join(data_dir, res_name)) + if os.path.isfile(filename): + return filename + + # look in ovos_utils package itself + found = resolve_ovos_resource_file(res_name) + if found: + return found + + # Finally look for it in the source package + paths = [ + "/opt/venvs/mycroft-core/lib/python3.7/site-packages/", # mark1/2 + "/opt/venvs/mycroft-core/lib/python3.4/site-packages/ ", + # old mark1 installs + "/home/pi/mycroft-core" # picroft + ] + if root_path: + paths += [root_path] + for p in paths: + filename = os.path.join(p, 'mycroft', 'res', res_name) + filename = os.path.abspath(os.path.normpath(filename)) + if os.path.isfile(filename): + return filename + + return None # Resource cannot be resolved + + +def read_vocab_file(path): + """ Read voc file. + + This reads a .voc file, stripping out empty lines comments and expand + parentheses. It returns each line as a list of all expanded + alternatives. + + Args: + path (str): path to vocab file. + + Returns: + List of Lists of strings. + """ + vocab = [] + with open(path, 'r', encoding='utf8') as voc_file: + for line in voc_file.readlines(): + if line.startswith('#') or line.strip() == '': + continue + vocab.append(expand_options(line.lower())) + return vocab + + +def load_regex_from_file(path, skill_id): + """Load regex from file + The regex is sent to the intent handler using the message bus + + Args: + path: path to vocabulary file (*.voc) + skill_id: skill_id to the regex is tied to + """ + regexes = [] + if path.endswith('.rx'): + with open(path, 'r', encoding='utf8') as reg_file: + for line in reg_file.readlines(): + if line.startswith("#"): + continue + LOG.debug('regex pre-munge: ' + line.strip()) + regex = munge_regex(line.strip(), skill_id) + LOG.debug('regex post-munge: ' + regex) + # Raise error if regex can't be compiled + try: + re.compile(regex) + regexes.append(regex) + except Exception as e: + LOG.warning(f'Failed to compile regex {regex}: {e}') + + return regexes + + +def load_vocabulary(basedir, skill_id): + """Load vocabulary from all files in the specified directory. + + Args: + basedir (str): path of directory to load from (will recurse) + skill_id: skill the data belongs to + Returns: + dict with intent_type as keys and list of list of lists as value. + """ + vocabs = {} + for path, _, files in walk(basedir): + for f in files: + if f.endswith(".voc"): + vocab_type = to_alnum(skill_id) + splitext(f)[0] + vocs = read_vocab_file(join(path, f)) + if vocs: + vocabs[vocab_type] = vocs + return vocabs + + +def load_regex(basedir, skill_id): + """Load regex from all files in the specified directory. + + Args: + basedir (str): path of directory to load from + bus (messagebus emitter): messagebus instance used to send the vocab to + the intent service + skill_id (str): skill identifier + """ + regexes = [] + for path, _, files in walk(basedir): + for f in files: + if f.endswith(".rx"): + regexes += load_regex_from_file(join(path, f), skill_id) + return regexes + + +def read_value_file(filename, delim): + """Read value file. + + The value file is a simple csv structure with a key and value. + + Args: + filename (str): file to read + delim (str): csv delimiter + + Returns: + OrderedDict with results. + """ + result = collections.OrderedDict() + + if filename: + with open(filename) as f: + reader = csv.reader(f, delimiter=delim) + for row in reader: + # skip blank or comment lines + if not row or row[0].startswith("#"): + continue + if len(row) != 2: + continue + + result[row[0]] = row[1] + return result + + +def read_translated_file(filename, data): + """Read a file inserting data. + + Args: + filename (str): file to read + data (dict): dictionary with data to insert into file + + Returns: + list of lines. + """ + if filename: + with open(filename) as f: + text = f.read().replace('{{', '{').replace('}}', '}') + return text.format(**data or {}).rstrip('\n').split('\n') + else: + return None diff --git a/ovos_utils/intents/__init__.py b/ovos_utils/intents/__init__.py index 6165119..ff9e985 100644 --- a/ovos_utils/intents/__init__.py +++ b/ovos_utils/intents/__init__.py @@ -1,417 +1,4 @@ -from ovos_utils.log import LOG -from ovos_utils.messagebus import get_mycroft_bus, Message -import time -from os.path import isfile - - -class IntentQueryApi: - """ - Query Intent Service at runtime - """ - - def __init__(self, bus=None, timeout=5): - self.bus = bus or get_mycroft_bus() - self.timeout = timeout - - def get_adapt_intent(self, utterance, lang="en-us"): - """ get best adapt intent for utterance """ - msg = Message("intent.service.adapt.get", - {"utterance": utterance, "lang": lang}, - context={"destination": "intent_service", - "source": "intent_api"}) - - resp = self.bus.wait_for_response(msg, - 'intent.service.adapt.reply', - timeout=self.timeout) - data = resp.data if resp is not None else {} - if not data: - LOG.error("Intent Service timed out!") - return None - return data["intent"] - - def get_padatious_intent(self, utterance, lang="en-us"): - """ get best padatious intent for utterance """ - msg = Message("intent.service.padatious.get", - {"utterance": utterance, "lang": lang}, - context={"destination": "intent_service", - "source": "intent_api"}) - resp = self.bus.wait_for_response(msg, - 'intent.service.padatious.reply', - timeout=self.timeout) - data = resp.data if resp is not None else {} - if not data: - LOG.error("Intent Service timed out!") - return None - return data["intent"] - - def get_intent(self, utterance, lang="en-us"): - """ get best intent for utterance """ - msg = Message("intent.service.intent.get", - {"utterance": utterance, "lang": lang}, - context={"destination": "intent_service", - "source": "intent_api"}) - resp = self.bus.wait_for_response(msg, - 'intent.service.intent.reply', - timeout=self.timeout) - data = resp.data if resp is not None else {} - if not data: - LOG.error("Intent Service timed out!") - return None - return data["intent"] - - def get_skill(self, utterance, lang="en-us"): - """ get skill that utterance will trigger """ - intent = self.get_intent(utterance, lang) - if not intent: - return None - # theoretically skill_id might be missing - if intent.get("skill_id"): - return intent["skill_id"] - # retrieve skill from munged intent name - if intent.get("intent_name"): # padatious + adapt - return intent["name"].split(":")[0] - if intent.get("intent_type"): # adapt - return intent["intent_type"].split(":")[0] - return None # raise some error here maybe? this should never happen - - def get_skills_manifest(self): - msg = Message("intent.service.skills.get", - context={"destination": "intent_service", - "source": "intent_api"}) - resp = self.bus.wait_for_response(msg, - 'intent.service.skills.reply', - timeout=self.timeout) - data = resp.data if resp is not None else {} - if not data: - LOG.error("Intent Service timed out!") - return None - return data["skills"] - - def get_active_skills(self, include_timestamps=False): - msg = Message("intent.service.active_skills.get", - context={"destination": "intent_service", - "source": "intent_api"}) - resp = self.bus.wait_for_response(msg, - 'intent.service.active_skills.reply', - timeout=self.timeout) - data = resp.data if resp is not None else {} - if not data: - LOG.error("Intent Service timed out!") - return None - if include_timestamps: - return data["skills"] - # HACK waiting for https://github.com/MycroftAI/mycroft-core/pull/2786 - if len(data["skills"]) and not len(data["skills"][0]) == 2: - return data["skills"] - return [s[0] for s in data["skills"]] - - def get_adapt_manifest(self): - msg = Message("intent.service.adapt.manifest.get", - context={"destination": "intent_service", - "source": "intent_api"}) - resp = self.bus.wait_for_response(msg, - 'intent.service.adapt.manifest', - timeout=self.timeout) - data = resp.data if resp is not None else {} - if not data: - LOG.error("Intent Service timed out!") - return None - return data["intents"] - - def get_padatious_manifest(self): - msg = Message("intent.service.padatious.manifest.get", - context={"destination": "intent_service", - "source": "intent_api"}) - resp = self.bus.wait_for_response(msg, - 'intent.service.padatious.manifest', - timeout=self.timeout) - data = resp.data if resp is not None else {} - if not data: - LOG.error("Intent Service timed out!") - return None - return data["intents"] - - def get_intent_manifest(self): - padatious = self.get_padatious_manifest() - adapt = self.get_adapt_manifest() - return {"adapt": adapt, - "padatious": padatious} - - def get_vocab_manifest(self): - msg = Message("intent.service.adapt.vocab.manifest.get", - context={"destination": "intent_service", - "source": "intent_api"}) - reply_msg_type = 'intent.service.adapt.vocab.manifest' - resp = self.bus.wait_for_response(msg, - reply_msg_type, - timeout=self.timeout) - data = resp.data if resp is not None else {} - if not data: - LOG.error("Intent Service timed out!") - return None - - vocab = {} - for voc in data["vocab"]: - if voc.get("regex"): - continue - if voc["end"] not in vocab: - vocab[voc["end"]] = {"samples": []} - vocab[voc["end"]]["samples"].append(voc["start"]) - return [{"name": voc, "samples": vocab[voc]["samples"]} - for voc in vocab] - - def get_regex_manifest(self): - msg = Message("intent.service.adapt.vocab.manifest.get", - context={"destination": "intent_service", - "source": "intent_api"}) - reply_msg_type = 'intent.service.adapt.vocab.manifest' - resp = self.bus.wait_for_response(msg, - reply_msg_type, - timeout=self.timeout) - data = resp.data if resp is not None else {} - if not data: - LOG.error("Intent Service timed out!") - return None - - vocab = {} - for voc in data["vocab"]: - if not voc.get("regex"): - continue - name = voc["regex"].split("(?P<")[-1].split(">")[0] - if name not in vocab: - vocab[name] = {"samples": []} - vocab[name]["samples"].append(voc["regex"]) - return [{"name": voc, "regexes": vocab[voc]["samples"]} - for voc in vocab] - - def get_entities_manifest(self): - msg = Message("intent.service.padatious.entities.manifest.get", - context={"destination": "intent_service", - "source": "intent_api"}) - reply_msg_type = 'intent.service.padatious.entities.manifest' - resp = self.bus.wait_for_response(msg, - reply_msg_type, - timeout=self.timeout) - data = resp.data if resp is not None else {} - if not data: - LOG.error("Intent Service timed out!") - return None - - entities = [] - # read files - for ent in data["entities"]: - if isfile(ent["file_name"]): - with open(ent["file_name"]) as f: - lines = f.read().replace("(", "").replace(")", "").split( - "\n") - samples = [] - for l in lines: - samples += [a.strip() for a in l.split("|") if a.strip()] - entities.append({"name": ent["name"], "samples": samples}) - return entities - - def get_keywords_manifest(self): - padatious = self.get_entities_manifest() - adapt = self.get_vocab_manifest() - regex = self.get_regex_manifest() - return {"adapt": adapt, - "padatious": padatious, - "regex": regex} - - -class ConverseTracker: - """ Using the messagebus this class recreates/keeps track of the state - of the converse system, it uses both passive listening and active - queries to sync it's state, it also emits 2 new bus events - - Implements https://github.com/MycroftAI/mycroft-core/pull/1468 - """ - bus = None - active_skills = [] - converse_timeout = 5 # MAGIC NUMBER hard coded in mycroft-core - last_conversed = None - intent_api = None - - @classmethod - def connect_bus(cls, mycroft_bus): - """Registers the bus object to use.""" - # PATCH - in mycroft-core this would be handled in intent_service - # in here it is done in MycroftSkill.bind so i added this - # conditional check - if cls.bus is None and mycroft_bus is not None: - cls.bus = mycroft_bus - cls.intent_api = IntentQueryApi(cls.bus) - cls.register_bus_events() - - @classmethod - def register_bus_events(cls): - cls.bus.on('active_skill_request', cls.handle_activate_request) - cls.bus.on('skill.converse.response', cls.handle_converse_response) - cls.bus.on("mycroft.skill.handler.start", cls.handle_intent_start) - cls.bus.on("recognizer_loop:utterance", cls.handle_utterance) - - # public methods - @classmethod - def check_skill(cls, skill_id): - """ Check if a skill is active """ - cls.filter_active_skills() - for skill in list(cls.active_skills): - if skill[0] == skill_id: - return True - return False - - @classmethod - def filter_active_skills(cls): - """ Removes expired skills from active skill list """ - # filter timestamps - for skill in list(cls.active_skills): - if time.time() - skill[1] <= cls.converse_timeout * 60: - cls.remove_active_skill(skill[0]) - - @classmethod - def sync_with_intent_service(cls): - """sync active skill list using intent api - - WARNING - we don't have the timestamps so order might be messed up!! - avoid calling this until - """ - skill_ids = cls.intent_api.get_active_skills(include_timestamps=True) - if skill_ids: - if len(skill_ids[0]) == 2: - # PR was merged! hurray! - cls.active_skills = skill_ids - else: - # hoping they come sorted by timestamp.... - # older to newer (most recently used) - for skill_id in reversed(skill_ids): - # are we tracking this skill ? - if not cls.check_skill(skill_id): - # we missed adding this skill in our tracking - cls.add_active_skill(skill_id) - for skill in cls.active_skills: - if skill[0] not in skill_ids: - # we missed removing this skill in our tracking - cls.remove_active_skill(skill[0]) - - # https://github.com/MycroftAI/mycroft-core/pull/1468 - @classmethod - def remove_active_skill(cls, skill_id, silent=False): - """ - Emits "converse.skill.deactivated" event, improvement of #1468 - """ - for skill in list(cls.active_skills): - if skill[0] == skill_id: - cls.active_skills.remove(skill) - if not silent: - cls.bus.emit(Message("converse.skill.deactivated", - {"skill_id": skill[0]})) - - @classmethod - def add_active_skill(cls, skill_id): - """ - Emits "converse.skill.activated" event, improvement of #1468 - """ - # search the list for an existing entry that already contains it - # and remove that reference - if skill_id != '': - cls.remove_active_skill(skill_id, silent=True) - # add skill with timestamp to start of skill_list - cls.active_skills.insert(0, [skill_id, time.time()]) - # this might be sent more than once and it's perfectly fine - # it's just a new info message not consumed anywhere by default - cls.bus.emit(Message("converse.skill.activated", - {"skill_id": skill_id})) - else: - LOG.warning('Skill ID was empty, won\'t add to list of ' - 'active skills.') - - # status tracking - @classmethod - def handle_activate_request(cls, message): - """ - a skill bumped itself to the top of active skills list - duplicate functionality from mycroft-core, keeping list in sync - """ - skill_id = message.data["skill_id"] - cls.add_active_skill(skill_id) - - @classmethod - def handle_converse_error(cls, message): - """ - a skill was removed from active skill list due to converse error - duplicate functionality from mycroft-core, keeping list in sync - """ - skill_id = message.data["skill_id"] - if message.data["error"] == "skill id does not exist": - cls.remove_active_skill(skill_id) - - @classmethod - def handle_intent_start(cls, message): - """ - duplicate functionality from mycroft-core, keeping list in sync - - TODO skill_id from message, core is not passing it along... it used - to be possible to retrieve it from munged message but that changed. - send a PR (if those got merged this code wouldn't exist) - - handle_utterance will take over this functionality for now - handle_converse_response will take corrective action - """ - # skill_id = message.data["skill_id"] - # bump skill to top of active list - # cls.add_active_skill(skill_id) - - @classmethod - def handle_utterance(cls, message): - """ - duplicate functionality from mycroft-core, keeping list in sync - - WORKAROUND - skill_id missing in handle_intent_start, will keep list - in sync by using the IntentAPI to check what skill the utterance - should trigger - - handle_converse_response will take corrective action - """ - # NOTE borked in mycroft-core - # needs https://github.com/MycroftAI/mycroft-core/pull/2786 - skill_id = cls.intent_api.get_skill(message.data["utterances"][0]) - if skill_id: - # this skill will trigger and therefore is the last active skill - cls.add_active_skill(skill_id) - # will remove expired intents from list - cls.filter_active_skills() - - @classmethod - def handle_converse_response(cls, message): - """ - tracks last_conversed skill - - FAILSAFE - additional checks to correct active skills list, - but that should never happen, accounts for mistakes in - handle_utterance / intent_api - - accounts for https://github.com/MycroftAI/mycroft-core/pull/2786 - not yet being merged - """ - skill_id = message.data["skill_id"] - if 'error' in message.data: - cls.handle_converse_error(message) - elif message.data.get('result') is True: - cls.last_conversed = skill_id - if not cls.check_skill(skill_id): - # seems like we missed adding this skill to active list - # NOTE this is a failsafe and should never trigger - # since this answered True we have the real timestamp - cls.add_active_skill(skill_id) - elif not cls.check_skill(skill_id): - # seems like we missed adding this skill to active list - # NOTE this is a failsafe and should never trigger - # since this answered false and we don't have the real timestamp - # let's add it to the end of the active_skills list - ts = time.time() - if len(cls.active_skills): - ts = cls.active_skills[-1][1] - cls.active_skills.append([skill_id, ts]) - - +from ovos_utils.intents.intent_service_interface import IntentQueryApi, \ + IntentServiceInterface +from ovos_utils.intents.converse import ConverseTracker +from ovos_utils.intents.layers import IntentLayers diff --git a/ovos_utils/intents/converse.py b/ovos_utils/intents/converse.py new file mode 100644 index 0000000..7970585 --- /dev/null +++ b/ovos_utils/intents/converse.py @@ -0,0 +1,201 @@ +import time + +from ovos_utils.intents.intent_service_interface import IntentQueryApi +from ovos_utils.log import LOG +from ovos_utils.messagebus import Message + + +class ConverseTracker: + """ Using the messagebus this class recreates/keeps track of the state + of the converse system, it uses both passive listening and active + queries to sync it's state, it also emits 2 new bus events + + Implements https://github.com/MycroftAI/mycroft-core/pull/1468 + """ + bus = None + active_skills = [] + converse_timeout = 5 # MAGIC NUMBER hard coded in mycroft-core + last_conversed = None + intent_api = None + + @classmethod + def connect_bus(cls, mycroft_bus): + """Registers the bus object to use.""" + # PATCH - in mycroft-core this would be handled in intent_service + # in here it is done in MycroftSkill.bind so i added this + # conditional check + if cls.bus is None and mycroft_bus is not None: + cls.bus = mycroft_bus + cls.intent_api = IntentQueryApi(cls.bus) + cls.register_bus_events() + + @classmethod + def register_bus_events(cls): + cls.bus.on('active_skill_request', cls.handle_activate_request) + cls.bus.on('skill.converse.response', cls.handle_converse_response) + cls.bus.on("mycroft.skill.handler.start", cls.handle_intent_start) + cls.bus.on("recognizer_loop:utterance", cls.handle_utterance) + + # public methods + @classmethod + def check_skill(cls, skill_id): + """ Check if a skill is active """ + cls.filter_active_skills() + for skill in list(cls.active_skills): + if skill[0] == skill_id: + return True + return False + + @classmethod + def filter_active_skills(cls): + """ Removes expired skills from active skill list """ + # filter timestamps + for skill in list(cls.active_skills): + if time.time() - skill[1] <= cls.converse_timeout * 60: + cls.remove_active_skill(skill[0]) + + @classmethod + def sync_with_intent_service(cls): + """sync active skill list using intent api + + WARNING + we don't have the timestamps so order might be messed up!! + avoid calling this until + """ + skill_ids = cls.intent_api.get_active_skills(include_timestamps=True) + if skill_ids: + if len(skill_ids[0]) == 2: + # PR was merged! hurray! + cls.active_skills = skill_ids + else: + # hoping they come sorted by timestamp.... + # older to newer (most recently used) + for skill_id in reversed(skill_ids): + # are we tracking this skill ? + if not cls.check_skill(skill_id): + # we missed adding this skill in our tracking + cls.add_active_skill(skill_id) + for skill in cls.active_skills: + if skill[0] not in skill_ids: + # we missed removing this skill in our tracking + cls.remove_active_skill(skill[0]) + + # https://github.com/MycroftAI/mycroft-core/pull/1468 + @classmethod + def remove_active_skill(cls, skill_id, silent=False): + """ + Emits "converse.skill.deactivated" event, improvement of #1468 + """ + for skill in list(cls.active_skills): + if skill[0] == skill_id: + cls.active_skills.remove(skill) + if not silent: + cls.bus.emit(Message("converse.skill.deactivated", + {"skill_id": skill[0]})) + + @classmethod + def add_active_skill(cls, skill_id): + """ + Emits "converse.skill.activated" event, improvement of #1468 + """ + # search the list for an existing entry that already contains it + # and remove that reference + if skill_id != '': + cls.remove_active_skill(skill_id, silent=True) + # add skill with timestamp to start of skill_list + cls.active_skills.insert(0, [skill_id, time.time()]) + # this might be sent more than once and it's perfectly fine + # it's just a new info message not consumed anywhere by default + cls.bus.emit(Message("converse.skill.activated", + {"skill_id": skill_id})) + else: + LOG.warning('Skill ID was empty, won\'t add to list of ' + 'active skills.') + + # status tracking + @classmethod + def handle_activate_request(cls, message): + """ + a skill bumped itself to the top of active skills list + duplicate functionality from mycroft-core, keeping list in sync + """ + skill_id = message.data["skill_id"] + cls.add_active_skill(skill_id) + + @classmethod + def handle_converse_error(cls, message): + """ + a skill was removed from active skill list due to converse error + duplicate functionality from mycroft-core, keeping list in sync + """ + skill_id = message.data["skill_id"] + if message.data["error"] == "skill id does not exist": + cls.remove_active_skill(skill_id) + + @classmethod + def handle_intent_start(cls, message): + """ + duplicate functionality from mycroft-core, keeping list in sync + + TODO skill_id from message, core is not passing it along... it used + to be possible to retrieve it from munged message but that changed. + send a PR (if those got merged this code wouldn't exist) + + handle_utterance will take over this functionality for now + handle_converse_response will take corrective action + """ + # skill_id = message.data["skill_id"] + # bump skill to top of active list + # cls.add_active_skill(skill_id) + + @classmethod + def handle_utterance(cls, message): + """ + duplicate functionality from mycroft-core, keeping list in sync + + WORKAROUND - skill_id missing in handle_intent_start, will keep list + in sync by using the IntentAPI to check what skill the utterance + should trigger + + handle_converse_response will take corrective action + """ + # NOTE borked in mycroft-core + # needs https://github.com/MycroftAI/mycroft-core/pull/2786 + skill_id = cls.intent_api.get_skill(message.data["utterances"][0]) + if skill_id: + # this skill will trigger and therefore is the last active skill + cls.add_active_skill(skill_id) + # will remove expired intents from list + cls.filter_active_skills() + + @classmethod + def handle_converse_response(cls, message): + """ + tracks last_conversed skill + + FAILSAFE - additional checks to correct active skills list, + but that should never happen, accounts for mistakes in + handle_utterance / intent_api + + accounts for https://github.com/MycroftAI/mycroft-core/pull/2786 + not yet being merged + """ + skill_id = message.data["skill_id"] + if 'error' in message.data: + cls.handle_converse_error(message) + elif message.data.get('result') is True: + cls.last_conversed = skill_id + if not cls.check_skill(skill_id): + # seems like we missed adding this skill to active list + # NOTE this is a failsafe and should never trigger + # since this answered True we have the real timestamp + cls.add_active_skill(skill_id) + elif not cls.check_skill(skill_id): + # seems like we missed adding this skill to active list + # NOTE this is a failsafe and should never trigger + # since this answered false and we don't have the real timestamp + # let's add it to the end of the active_skills list + ts = time.time() + if len(cls.active_skills): + ts = cls.active_skills[-1][1] + cls.active_skills.append([skill_id, ts]) diff --git a/ovos_utils/intents/intent_service_interface.py b/ovos_utils/intents/intent_service_interface.py new file mode 100644 index 0000000..25eaceb --- /dev/null +++ b/ovos_utils/intents/intent_service_interface.py @@ -0,0 +1,495 @@ +from os.path import exists, isfile + +from adapt.intent import Intent +from mycroft_bus_client import MessageBusClient +from mycroft_bus_client.message import Message, dig_for_message +from ovos_utils import create_daemon +from ovos_utils.log import LOG + + +def to_alnum(skill_id): + """Convert a skill id to only alphanumeric characters + + Non alpha-numeric characters are converted to "_" + + Args: + skill_id (str): identifier to be converted + Returns: + (str) String of letters + """ + return ''.join(c if c.isalnum() else '_' for c in str(skill_id)) + + +def munge_regex(regex, skill_id): + """Insert skill id as letters into match groups. + + Args: + regex (str): regex string + skill_id (str): skill identifier + Returns: + (str) munged regex + """ + base = '(?P<' + to_alnum(skill_id) + return base.join(regex.split('(?P<')) + + +def munge_intent_parser(intent_parser, name, skill_id): + """Rename intent keywords to make them skill exclusive + This gives the intent parser an exclusive name in the + format :. The keywords are given unique + names in the format . + + The function will not munge instances that's already been + munged + + Args: + intent_parser: (IntentParser) object to update + name: (str) Skill name + skill_id: (int) skill identifier + """ + # Munge parser name + if not name.startswith(str(skill_id) + ':'): + intent_parser.name = str(skill_id) + ':' + name + else: + intent_parser.name = name + + # Munge keywords + skill_id = to_alnum(skill_id) + # Munge required keyword + reqs = [] + for i in intent_parser.requires: + if not i[0].startswith(skill_id): + kw = (skill_id + i[0], skill_id + i[0]) + reqs.append(kw) + else: + reqs.append(i) + intent_parser.requires = reqs + + # Munge optional keywords + opts = [] + for i in intent_parser.optional: + if not i[0].startswith(skill_id): + kw = (skill_id + i[0], skill_id + i[0]) + opts.append(kw) + else: + opts.append(i) + intent_parser.optional = opts + + # Munge at_least_one keywords + at_least_one = [] + for i in intent_parser.at_least_one: + element = [skill_id + e.replace(skill_id, '') for e in i] + at_least_one.append(tuple(element)) + intent_parser.at_least_one = at_least_one + + +class IntentServiceInterface: + """Interface to communicate with the Mycroft intent service. + + This class wraps the messagebus interface of the intent service allowing + for easier interaction with the service. It wraps both the Adapt and + Padatious parts of the intent services. + """ + + def __init__(self, bus=None): + self.bus = bus + self.skill_id = self.__class__.__name__ + self.registered_intents = [] + self.detached_intents = [] + + def set_bus(self, bus): + self.bus = bus + + def set_id(self, skill_id): + self.skill_id = skill_id + + def register_adapt_keyword(self, vocab_type, entity, aliases=None, + lang=None): + """Send a message to the intent service to add an Adapt keyword. + + vocab_type(str): Keyword reference + entity (str): Primary keyword + aliases (list): List of alternative kewords + """ + aliases = aliases or [] + msg = dig_for_message() or Message("") + if "skill_id" not in msg.context: + msg.context["skill_id"] = self.skill_id + self.bus.emit(msg.forward("register_vocab", + {'start': entity, + 'end': vocab_type, + 'lang': lang})) + for alias in aliases: + self.bus.emit(msg.forward("register_vocab", + {'start': alias, + 'end': vocab_type, + 'alias_of': entity, + 'lang': lang})) + + def register_adapt_regex(self, regex, lang=None): + """Register a regex with the intent service. + + Args: + regex (str): Regex to be registered, (Adapt extracts keyword + reference from named match group. + """ + msg = dig_for_message() or Message("") + if "skill_id" not in msg.context: + msg.context["skill_id"] = self.skill_id + self.bus.emit(msg.forward("register_vocab", + {'regex': regex, 'lang': lang})) + + def register_adapt_intent(self, name, intent_parser): + """Register an Adapt intent parser object. + + Serializes the intent_parser and sends it over the messagebus to + registered. + """ + msg = dig_for_message() or Message("") + if "skill_id" not in msg.context: + msg.context["skill_id"] = self.skill_id + self.bus.emit(msg.forward("register_intent", intent_parser.__dict__)) + self.registered_intents.append((name, intent_parser)) + self.detached_intents = [detached for detached in self.detached_intents + if detached[0] != name] + + def detach_intent(self, intent_name): + """Remove an intent from the intent service. + + The intent is saved in the list of detached intents for use when + re-enabling an intent. + + Args: + intent_name(str): Intent reference + """ + name = intent_name.split(':')[1] + if name in self: + msg = dig_for_message() or Message("") + if "skill_id" not in msg.context: + msg.context["skill_id"] = self.skill_id + self.bus.emit(msg.forward("detach_intent", + {"intent_name": intent_name})) + self.detached_intents.append((name, self.get_intent(name))) + self.registered_intents = [pair for pair in self.registered_intents + if pair[0] != name] + + def set_adapt_context(self, context, word, origin): + """Set an Adapt context. + + Args: + context (str): context keyword name + word (str): word to register + origin (str): original origin of the context (for cross context) + """ + msg = dig_for_message() or Message("") + if "skill_id" not in msg.context: + msg.context["skill_id"] = self.skill_id + self.bus.emit(msg.forward('add_context', + {'context': context, 'word': word, + 'origin': origin})) + + def remove_adapt_context(self, context): + """Remove an active Adapt context. + + Args: + context(str): name of context to remove + """ + msg = dig_for_message() or Message("") + if "skill_id" not in msg.context: + msg.context["skill_id"] = self.skill_id + self.bus.emit(msg.forward('remove_context', {'context': context})) + + def register_padatious_intent(self, intent_name, filename, lang): + """Register a padatious intent file with Padatious. + + Args: + intent_name(str): intent identifier + filename(str): complete file path for entity file + """ + if not isinstance(filename, str): + raise ValueError('Filename path must be a string') + if not exists(filename): + raise FileNotFoundError('Unable to find "{}"'.format(filename)) + + data = {'file_name': filename, + 'name': intent_name, + 'lang': lang} + msg = dig_for_message() or Message("") + if "skill_id" not in msg.context: + msg.context["skill_id"] = self.skill_id + self.bus.emit(msg.forward("padatious:register_intent", data)) + self.registered_intents.append((intent_name.split(':')[-1], data)) + + def register_padatious_entity(self, entity_name, filename, lang): + """Register a padatious entity file with Padatious. + + Args: + entity_name(str): entity name + filename(str): complete file path for entity file + """ + if not isinstance(filename, str): + raise ValueError('Filename path must be a string') + if not exists(filename): + raise FileNotFoundError('Unable to find "{}"'.format(filename)) + msg = dig_for_message() or Message("") + if "skill_id" not in msg.context: + msg.context["skill_id"] = self.skill_id + self.bus.emit(msg.forward('padatious:register_entity', + {'file_name': filename, + 'name': entity_name, + 'lang': lang})) + + def __iter__(self): + """Iterator over the registered intents. + + Returns an iterator returning name-handler pairs of the registered + intent handlers. + """ + return iter(self.registered_intents) + + def __contains__(self, val): + """Checks if an intent name has been registered.""" + return val in [i[0] for i in self.registered_intents] + + def get_intent(self, intent_name): + """Get intent from intent_name. + + This will find both enabled and disabled intents. + + Args: + intent_name (str): name to find. + + Returns: + Found intent or None if none were found. + """ + for name, intent in self: + if name == intent_name: + return intent + for name, intent in self.detached_intents: + if name == intent_name: + return intent + return None + + +class IntentQueryApi: + """ + Query Intent Service at runtime + """ + + def __init__(self, bus=None, timeout=5): + if bus is None: + bus = MessageBusClient() + create_daemon(bus.run_forever) + self.bus = bus + self.timeout = timeout + + def get_adapt_intent(self, utterance, lang="en-us"): + """ get best adapt intent for utterance """ + msg = Message("intent.service.adapt.get", + {"utterance": utterance, "lang": lang}, + context={"destination": "intent_service", + "source": "intent_api"}) + + resp = self.bus.wait_for_response(msg, + 'intent.service.adapt.reply', + timeout=self.timeout) + data = resp.data if resp is not None else {} + if not data: + LOG.error("Intent Service timed out!") + return None + return data["intent"] + + def get_padatious_intent(self, utterance, lang="en-us"): + """ get best padatious intent for utterance """ + msg = Message("intent.service.padatious.get", + {"utterance": utterance, "lang": lang}, + context={"destination": "intent_service", + "source": "intent_api"}) + resp = self.bus.wait_for_response(msg, + 'intent.service.padatious.reply', + timeout=self.timeout) + data = resp.data if resp is not None else {} + if not data: + LOG.error("Intent Service timed out!") + return None + return data["intent"] + + def get_intent(self, utterance, lang="en-us"): + """ get best intent for utterance """ + msg = Message("intent.service.intent.get", + {"utterance": utterance, "lang": lang}, + context={"destination": "intent_service", + "source": "intent_api"}) + resp = self.bus.wait_for_response(msg, + 'intent.service.intent.reply', + timeout=self.timeout) + data = resp.data if resp is not None else {} + if not data: + LOG.error("Intent Service timed out!") + return None + return data["intent"] + + def get_skill(self, utterance, lang="en-us"): + """ get skill that utterance will trigger """ + intent = self.get_intent(utterance, lang) + if not intent: + return None + # theoretically skill_id might be missing + if intent.get("skill_id"): + return intent["skill_id"] + # retrieve skill from munged intent name + if intent.get("intent_name"): # padatious + adapt + return intent["name"].split(":")[0] + if intent.get("intent_type"): # adapt + return intent["intent_type"].split(":")[0] + return None # raise some error here maybe? this should never happen + + def get_skills_manifest(self): + msg = Message("intent.service.skills.get", + context={"destination": "intent_service", + "source": "intent_api"}) + resp = self.bus.wait_for_response(msg, + 'intent.service.skills.reply', + timeout=self.timeout) + data = resp.data if resp is not None else {} + if not data: + LOG.error("Intent Service timed out!") + return None + return data["skills"] + + def get_active_skills(self, include_timestamps=False): + msg = Message("intent.service.active_skills.get", + context={"destination": "intent_service", + "source": "intent_api"}) + resp = self.bus.wait_for_response(msg, + 'intent.service.active_skills.reply', + timeout=self.timeout) + data = resp.data if resp is not None else {} + if not data: + LOG.error("Intent Service timed out!") + return None + if include_timestamps: + return data["skills"] + return [s[0] for s in data["skills"]] + + def get_adapt_manifest(self): + msg = Message("intent.service.adapt.manifest.get", + context={"destination": "intent_service", + "source": "intent_api"}) + resp = self.bus.wait_for_response(msg, + 'intent.service.adapt.manifest', + timeout=self.timeout) + data = resp.data if resp is not None else {} + if not data: + LOG.error("Intent Service timed out!") + return None + return data["intents"] + + def get_padatious_manifest(self): + msg = Message("intent.service.padatious.manifest.get", + context={"destination": "intent_service", + "source": "intent_api"}) + resp = self.bus.wait_for_response(msg, + 'intent.service.padatious.manifest', + timeout=self.timeout) + data = resp.data if resp is not None else {} + if not data: + LOG.error("Intent Service timed out!") + return None + return data["intents"] + + def get_intent_manifest(self): + padatious = self.get_padatious_manifest() + adapt = self.get_adapt_manifest() + return {"adapt": adapt, + "padatious": padatious} + + def get_vocab_manifest(self): + msg = Message("intent.service.adapt.vocab.manifest.get", + context={"destination": "intent_service", + "source": "intent_api"}) + reply_msg_type = 'intent.service.adapt.vocab.manifest' + resp = self.bus.wait_for_response(msg, + reply_msg_type, + timeout=self.timeout) + data = resp.data if resp is not None else {} + if not data: + LOG.error("Intent Service timed out!") + return None + + vocab = {} + for voc in data["vocab"]: + if voc.get("regex"): + continue + if voc["end"] not in vocab: + vocab[voc["end"]] = {"samples": []} + vocab[voc["end"]]["samples"].append(voc["start"]) + return [{"name": voc, "samples": vocab[voc]["samples"]} + for voc in vocab] + + def get_regex_manifest(self): + msg = Message("intent.service.adapt.vocab.manifest.get", + context={"destination": "intent_service", + "source": "intent_api"}) + reply_msg_type = 'intent.service.adapt.vocab.manifest' + resp = self.bus.wait_for_response(msg, + reply_msg_type, + timeout=self.timeout) + data = resp.data if resp is not None else {} + if not data: + LOG.error("Intent Service timed out!") + return None + + vocab = {} + for voc in data["vocab"]: + if not voc.get("regex"): + continue + name = voc["regex"].split("(?P<")[-1].split(">")[0] + if name not in vocab: + vocab[name] = {"samples": []} + vocab[name]["samples"].append(voc["regex"]) + return [{"name": voc, "regexes": vocab[voc]["samples"]} + for voc in vocab] + + def get_entities_manifest(self): + msg = Message("intent.service.padatious.entities.manifest.get", + context={"destination": "intent_service", + "source": "intent_api"}) + reply_msg_type = 'intent.service.padatious.entities.manifest' + resp = self.bus.wait_for_response(msg, + reply_msg_type, + timeout=self.timeout) + data = resp.data if resp is not None else {} + if not data: + LOG.error("Intent Service timed out!") + return None + + entities = [] + # read files + for ent in data["entities"]: + if isfile(ent["file_name"]): + with open(ent["file_name"]) as f: + lines = f.read().replace("(", "").replace(")", "").split( + "\n") + samples = [] + for l in lines: + samples += [a.strip() for a in l.split("|") if a.strip()] + entities.append({"name": ent["name"], "samples": samples}) + return entities + + def get_keywords_manifest(self): + padatious = self.get_entities_manifest() + adapt = self.get_vocab_manifest() + regex = self.get_regex_manifest() + return {"adapt": adapt, + "padatious": padatious, + "regex": regex} + + +def open_intent_envelope(message): + """Convert dictionary received over messagebus to Intent.""" + intent_dict = message.data + return Intent(intent_dict.get('name'), + intent_dict.get('requires'), + intent_dict.get('at_least_one'), + intent_dict.get('optional')) From c3b7ce30d1060251d1f3db894adcc780f10a7a1f Mon Sep 17 00:00:00 2001 From: jarbasal Date: Thu, 30 Sep 2021 00:55:13 +0100 Subject: [PATCH 2/5] dialog utils --- ovos_utils/dialog.py | 193 ++++++++++++++++++++++++++++++++++++ ovos_utils/lang/__init__.py | 26 +++++ 2 files changed, 219 insertions(+) create mode 100644 ovos_utils/dialog.py diff --git a/ovos_utils/dialog.py b/ovos_utils/dialog.py new file mode 100644 index 0000000..7b51900 --- /dev/null +++ b/ovos_utils/dialog.py @@ -0,0 +1,193 @@ +import os +import random +import re +from os.path import join +from pathlib import Path + +from ovos_utils.bracket_expansion import expand_options +from ovos_utils.configuration import read_mycroft_config +from ovos_utils.file_utils import resolve_resource_file +from ovos_utils.lang import translate_word +from ovos_utils.log import LOG + + +class MustacheDialogRenderer: + """A dialog template renderer based on the mustache templating language.""" + + def __init__(self): + self.templates = {} + self.recent_phrases = [] + + # TODO magic numbers are bad! + self.max_recent_phrases = 3 + # We cycle through lines in .dialog files to keep Mycroft from + # repeating the same phrase over and over. However, if a .dialog + # file only contains a few entries, this can cause it to loop. + # + # This offset will override max_recent_phrases on very short .dialog + # files. With the offset at 2, .dialog files with 3 or more lines will + # be managed to avoid repetition, but .dialog files with only 1 or 2 + # lines will be unaffected. Dialog should never get stuck in a loop. + self.loop_prevention_offset = 2 + + def load_template_file(self, template_name, filename): + """Load a template by file name into the templates cache. + + Args: + template_name (str): a unique identifier for a group of templates + filename (str): a fully qualified filename of a mustache template. + """ + with open(filename, 'r', encoding='utf8') as f: + for line in f: + template_text = line.strip() + # Skip all lines starting with '#' and all empty lines + if (not template_text.startswith('#') and + template_text != ''): + if template_name not in self.templates: + self.templates[template_name] = [] + + # convert to standard python format string syntax. From + # double (or more) '{' followed by any number of + # whitespace followed by actual key followed by any number + # of whitespace followed by double (or more) '}' + template_text = re.sub(r'\{\{+\s*(.*?)\s*\}\}+', r'{\1}', + template_text) + + self.templates[template_name].append(template_text) + + def render(self, template_name, context=None, index=None): + """ + Given a template name, pick a template and render it using the context. + If no matching template exists use template_name as template. + + Tries not to let Mycroft say exactly the same thing twice in a row. + + Args: + template_name (str): the name of a template group. + context (dict): dictionary representing values to be rendered + index (int): optional, the specific index in the collection of + templates + + Returns: + str: the rendered string + """ + context = context or {} + if template_name not in self.templates: + # When not found, return the name itself as the dialog + # This allows things like render("record.not.found") to either + # find a translation file "record.not.found.dialog" or return + # "record not found" literal. + return template_name.replace('.', ' ') + + # Get the .dialog file's contents, minus any which have been spoken + # recently. + template_functions = self.templates.get(template_name) + + if index is None: + template_functions = ([t for t in template_functions + if t not in self.recent_phrases] or + template_functions) + line = random.choice(template_functions) + else: + line = template_functions[index % len(template_functions)] + # Replace {key} in line with matching values from context + line = line.format(**context) + line = random.choice(expand_options(line)) + + # Here's where we keep track of what we've said recently. Remember, + # this is by line in the .dialog file, not by exact phrase + self.recent_phrases.append(line) + if (len(self.recent_phrases) > + min(self.max_recent_phrases, len(self.templates.get( + template_name)) - self.loop_prevention_offset)): + self.recent_phrases.pop(0) + return line + + +def load_dialogs(dialog_dir, renderer=None): + """Load all dialog files within the specified directory. + + Args: + dialog_dir (str): directory that contains dialog files + + Returns: + a loaded instance of a dialog renderer + """ + if renderer is None: + renderer = MustacheDialogRenderer() + + directory = Path(dialog_dir) + if not directory.exists() or not directory.is_dir(): + LOG.warning('No dialog files found: {}'.format(dialog_dir)) + return renderer + + for path, _, files in os.walk(str(directory)): + for f in files: + if f.endswith('.dialog'): + renderer.load_template_file(f.replace('.dialog', ''), + join(path, f)) + return renderer + + +def get_dialog(phrase, lang=None, context=None): + """Looks up a resource file for the given phrase. + + If no file is found, the requested phrase is returned as the string. This + will use the default language for translations. + + Args: + phrase (str): resource phrase to retrieve/translate + lang (str): the language to use + context (dict): values to be inserted into the string + + Returns: + str: a randomized and/or translated version of the phrase + """ + + if not lang: + try: + conf = read_mycroft_config() + lang = conf.get('lang') + except FileNotFoundError: + lang = "en-us" + + filename = join('text', lang.lower(), phrase + '.dialog') + template = resolve_resource_file(filename) + if not template: + LOG.debug('Resource file not found: {}'.format(filename)) + return phrase + + stache = MustacheDialogRenderer() + stache.load_template_file('template', template) + if not context: + context = {} + return stache.render('template', context) + + +def join_list(items, connector, sep=None, lang=''): + """ Join a list into a phrase using the given connector word + Examples: + join_list([1,2,3], "and") -> "1, 2 and 3" + join_list([1,2,3], "and", ";") -> "1; 2 and 3" + Args: + items (array): items to be joined + connector (str): connecting word (resource name), like "and" or "or" + sep (str, optional): separator character, default = "," + lang (str, optional): an optional BCP-47 language code, if omitted + the default language will be used. + Returns: + str: the connected list phrase + """ + + if not items: + return "" + if len(items) == 1: + return str(items[0]) + + if not sep: + sep = ", " + else: + sep += " " + return (sep.join(str(item) for item in items[:-1]) + + " " + translate_word(connector, lang) + + " " + items[-1]) diff --git a/ovos_utils/lang/__init__.py b/ovos_utils/lang/__init__.py index 28c8235..b43ccae 100644 --- a/ovos_utils/lang/__init__.py +++ b/ovos_utils/lang/__init__.py @@ -2,6 +2,7 @@ from os import system, makedirs, listdir from ovos_utils.lang.detect import detect_lang from ovos_utils.lang.translate import translate_text +from ovos_utils.file_utils import resolve_resource_file def get_tts(sentence, lang="en-us", mp3_file="/tmp/google_tx_tts.mp3"): @@ -42,3 +43,28 @@ def get_language_dir(base_path, lang="en-us"): if len(paths): return paths[0] return join(base_path, lang) + + +def translate_word(name, lang='en-us'): + """ Helper to get word translations + Args: + name (str): Word name. Returned as the default value if not translated + lang (str, optional): an optional BCP-47 language code, if omitted + the default language will be used. + Returns: + str: translated version of resource name + """ + filename = resolve_resource_file(join("text", lang, name + ".word")) + if filename: + # open the file + try: + with open(filename, 'r', encoding='utf8') as f: + for line in f: + word = line.strip() + if word.startswith("#"): + continue # skip comment lines + return word + except Exception: + pass + return name # use resource name as the word + From 3b271d6fa3c95b311b11438ab63be7ac1371e6ed Mon Sep 17 00:00:00 2001 From: jarbasal Date: Thu, 30 Sep 2021 06:17:20 +0100 Subject: [PATCH 3/5] circular import --- ovos_utils/file_utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ovos_utils/file_utils.py b/ovos_utils/file_utils.py index de9122a..9b3c44b 100644 --- a/ovos_utils/file_utils.py +++ b/ovos_utils/file_utils.py @@ -6,7 +6,6 @@ from os.path import splitext, join, dirname from ovos_utils.bracket_expansion import expand_options -from ovos_utils.intents.intent_service_interface import to_alnum, munge_regex from ovos_utils.log import LOG @@ -135,6 +134,8 @@ def load_regex_from_file(path, skill_id): path: path to vocabulary file (*.voc) skill_id: skill_id to the regex is tied to """ + from ovos_utils.intents.intent_service_interface import munge_regex + regexes = [] if path.endswith('.rx'): with open(path, 'r', encoding='utf8') as reg_file: @@ -163,6 +164,8 @@ def load_vocabulary(basedir, skill_id): Returns: dict with intent_type as keys and list of list of lists as value. """ + from ovos_utils.intents.intent_service_interface import to_alnum + vocabs = {} for path, _, files in walk(basedir): for f in files: From 1771ed406432ae85cae172c19e255f915c730a7d Mon Sep 17 00:00:00 2001 From: jarbasal Date: Thu, 30 Sep 2021 16:43:59 +0100 Subject: [PATCH 4/5] delayed bus init --- ovos_utils/gui.py | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/ovos_utils/gui.py b/ovos_utils/gui.py index 9b4a472..ea62f9c 100644 --- a/ovos_utils/gui.py +++ b/ovos_utils/gui.py @@ -429,7 +429,7 @@ class GUIInterface: """ def __init__(self, skill_id, bus=None, remote_server=None, config=None): - self.bus = bus or get_mycroft_bus() + self.bus = bus self.__session_data = {} # synced to GUI for use by this skill's pages self.pages = [] self.current_page_idx = -1 @@ -437,6 +437,11 @@ def __init__(self, skill_id, bus=None, remote_server=None, config=None): self.on_gui_changed_callback = None self.remote_url = remote_server self._events = [] + if bus: + self.set_bus(bus) + + def set_bus(self, bus=None): + self.bus = bus or get_mycroft_bus() self.setup_default_handlers() @property @@ -448,6 +453,8 @@ def page(self): def connected(self): """Returns True if at least 1 remote gui is connected or if gui is installed and running locally, else False""" + if not self.bus: + return False return can_use_gui(self.bus) def build_message_type(self, event): @@ -475,6 +482,8 @@ def register_handler(self, event, handler): event (str): event to catch handler: function to handle the event """ + if not self.bus: + raise RuntimeError("bus not set, did you call self.bind() ?") event = self.build_message_type(event) self._events.append((event, handler)) self.bus.on(event, handler) @@ -501,6 +510,8 @@ def gui_set(self, message): self.on_gui_changed_callback() def _sync_data(self): + if not self.bus: + raise RuntimeError("bus not set, did you call self.bind() ?") data = self.__session_data.copy() data.update({'__from': self.skill_id}) self.bus.emit(Message("gui.value.set", data)) @@ -539,6 +550,8 @@ def clear(self): self.__session_data = {} self.pages = [] self.current_page_idx = -1 + if not self.bus: + raise RuntimeError("bus not set, did you call self.bind() ?") self.bus.emit(Message("gui.clear.namespace", {"__from": self.skill_id})) @@ -551,6 +564,8 @@ def send_event(self, event_name, params=None): should be sent along with the request. """ params = params or {} + if not self.bus: + raise RuntimeError("bus not set, did you call self.bind() ?") self.bus.emit(Message("gui.event.send", {"__from": self.skill_id, "event_name": event_name, @@ -610,6 +625,8 @@ def show_pages(self, page_names, index=0, override_idle=None, True: Disables showing all platform skill animations. False: 'Default' always show animations. """ + if not self.bus: + raise RuntimeError("bus not set, did you call self.bind() ?") if isinstance(page_names, str): page_names = [page_names] if not isinstance(page_names, list): @@ -649,6 +666,8 @@ def remove_pages(self, page_names): page_names (list): List of page names (str) to display, such as ["Weather.qml", "Forecast.qml", "Other.qml"] """ + if not self.bus: + raise RuntimeError("bus not set, did you call self.bind() ?") if not isinstance(page_names, list): page_names = [page_names] page_urls = self._pages2uri(page_names) @@ -671,6 +690,8 @@ def show_notification(self, content, action=None, transient: 'Default' displays a notification with a timeout. sticky: displays a notification that sticks to the screen. """ + if not self.bus: + raise RuntimeError("bus not set, did you call self.bind() ?") self.bus.emit(Message("homescreen.notification.set", data={ "sender": self.skill_id, @@ -794,6 +815,8 @@ def release(self): allow different platforms to properly handle this event. Also calls self.clear() to reset the state variables Platforms can close the window or go back to previous page""" + if not self.bus: + raise RuntimeError("bus not set, did you call self.bind() ?") self.clear() self.bus.emit(Message("mycroft.gui.screen.close", {"skill_id": self.skill_id})) @@ -803,9 +826,10 @@ def shutdown(self): Clear pages loaded through this interface and remove the bus events """ - self.release() - for event, handler in self._events: - self.bus.remove(event, handler) + if self.bus: + self.release() + for event, handler in self._events: + self.bus.remove(event, handler) if __name__ == "__main__": From 6db561b684ee88fbf3bb9bd9906bf25d95432873 Mon Sep 17 00:00:00 2001 From: jarbasal Date: Fri, 1 Oct 2021 15:46:26 +0100 Subject: [PATCH 5/5] added some helper functions for intent handling / disabling --- ovos_utils/intents/intent_service_interface.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/ovos_utils/intents/intent_service_interface.py b/ovos_utils/intents/intent_service_interface.py index 25eaceb..a014880 100644 --- a/ovos_utils/intents/intent_service_interface.py +++ b/ovos_utils/intents/intent_service_interface.py @@ -162,7 +162,12 @@ def detach_intent(self, intent_name): Args: intent_name(str): Intent reference """ - name = intent_name.split(':')[1] + # split skill_id if needed + if intent_name not in self and ":" in intent_name: + name = intent_name.split(':')[1] + else: + name = intent_name + if name in self: msg = dig_for_message() or Message("") if "skill_id" not in msg.context: @@ -251,6 +256,15 @@ def __contains__(self, val): """Checks if an intent name has been registered.""" return val in [i[0] for i in self.registered_intents] + def get_intent_names(self): + return [i[0] for i in self.registered_intents] + + def detach_all(self): + for name in self.get_intent_names(): + self.detach_intent(name) + self.registered_intents = [] + self.detached_intents = [] + def get_intent(self, intent_name): """Get intent from intent_name.