diff --git a/.github/workflows/license_tests.yml b/.github/workflows/license_tests.yml index cc773f98..586443e0 100644 --- a/.github/workflows/license_tests.yml +++ b/.github/workflows/license_tests.yml @@ -11,3 +11,4 @@ jobs: uses: neongeckocom/.github/.github/workflows/license_tests.yml@master with: package-extras: audio,configuration,networking + packages-exclude: '^(precise-runner|fann2|tqdm|bs4|ovos-phal-plugin|ovos-skill|neon-core|nvidia|neon-phal-plugin|bitstruct|audioread).*' diff --git a/CHANGELOG.md b/CHANGELOG.md index 30598235..86fdf5dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,28 +1,52 @@ # Changelog -## [1.6.2a3](https://github.com/NeonGeckoCom/neon-utils/tree/1.6.2a3) (2023-08-22) +## [1.7.0](https://github.com/NeonGeckoCom/neon-utils/tree/1.7.0) (2023-10-26) -[Full Changelog](https://github.com/NeonGeckoCom/neon-utils/compare/1.6.2a2...1.6.2a3) +[Full Changelog](https://github.com/NeonGeckoCom/neon-utils/compare/1.6.3a5...1.7.0) + +**Fixed bugs:** + +- \[BUG\] Got some error/warning when the skill initialize [\#463](https://github.com/NeonGeckoCom/neon-utils/issues/463) + +## [1.6.3a5](https://github.com/NeonGeckoCom/neon-utils/tree/1.6.3a5) (2023-10-25) + +[Full Changelog](https://github.com/NeonGeckoCom/neon-utils/compare/1.6.3a4...1.6.3a5) + +**Merged pull requests:** + +- Troubleshooting Skill Class Init [\#481](https://github.com/NeonGeckoCom/neon-utils/pull/481) ([NeonDaniel](https://github.com/NeonDaniel)) + +## [1.6.3a4](https://github.com/NeonGeckoCom/neon-utils/tree/1.6.3a4) (2023-10-24) + +[Full Changelog](https://github.com/NeonGeckoCom/neon-utils/compare/1.6.3a3...1.6.3a4) + +**Merged pull requests:** + +- Update Skill base classes [\#480](https://github.com/NeonGeckoCom/neon-utils/pull/480) ([NeonDaniel](https://github.com/NeonDaniel)) + +## [1.6.3a3](https://github.com/NeonGeckoCom/neon-utils/tree/1.6.3a3) (2023-10-10) + +[Full Changelog](https://github.com/NeonGeckoCom/neon-utils/compare/1.6.3a2...1.6.3a3) **Merged pull requests:** -- Update logging for better readability [\#475](https://github.com/NeonGeckoCom/neon-utils/pull/475) ([NeonDaniel](https://github.com/NeonDaniel)) +- Better exception handling around signal method patching [\#479](https://github.com/NeonGeckoCom/neon-utils/pull/479) ([NeonDaniel](https://github.com/NeonDaniel)) -## [1.6.2a2](https://github.com/NeonGeckoCom/neon-utils/tree/1.6.2a2) (2023-08-16) +## [1.6.3a2](https://github.com/NeonGeckoCom/neon-utils/tree/1.6.3a2) (2023-10-10) -[Full Changelog](https://github.com/NeonGeckoCom/neon-utils/compare/1.6.2a1...1.6.2a2) +[Full Changelog](https://github.com/NeonGeckoCom/neon-utils/compare/1.6.3a1...1.6.3a2) **Merged pull requests:** -- Troubleshooting spoken responses in a non-default language [\#474](https://github.com/NeonGeckoCom/neon-utils/pull/474) ([NeonDaniel](https://github.com/NeonDaniel)) +- Skill Compatibility [\#478](https://github.com/NeonGeckoCom/neon-utils/pull/478) ([NeonDaniel](https://github.com/NeonDaniel)) -## [1.6.2a1](https://github.com/NeonGeckoCom/neon-utils/tree/1.6.2a1) (2023-08-11) +## [1.6.3a1](https://github.com/NeonGeckoCom/neon-utils/tree/1.6.3a1) (2023-10-10) -[Full Changelog](https://github.com/NeonGeckoCom/neon-utils/compare/1.6.1...1.6.2a1) +[Full Changelog](https://github.com/NeonGeckoCom/neon-utils/compare/1.6.2...1.6.3a1) **Merged pull requests:** -- Supported Languages bugfix and unit test [\#473](https://github.com/NeonGeckoCom/neon-utils/pull/473) ([NeonDaniel](https://github.com/NeonDaniel)) +- Emit handler complete event for CommonQuery skills [\#477](https://github.com/NeonGeckoCom/neon-utils/pull/477) ([NeonDaniel](https://github.com/NeonDaniel)) diff --git a/neon_utils/signal_utils.py b/neon_utils/signal_utils.py index ace86239..28e4e6d4 100644 --- a/neon_utils/signal_utils.py +++ b/neon_utils/signal_utils.py @@ -125,8 +125,8 @@ def init_signal_handlers(): mycroft.util.signal.create_signal = _create_signal mycroft.util.signal.check_for_signal = _check_for_signal LOG.info(f"Overrode mycroft.util.signal methods") - except ImportError: - pass + except (ImportError, AttributeError) as e: + LOG.debug(e) except TypeError as e: # This comes from tests overriding MessageBusClient() LOG.error(e) diff --git a/neon_utils/skills/common_play_skill.py b/neon_utils/skills/common_play_skill.py index 2b1c8f39..495af6c6 100644 --- a/neon_utils/skills/common_play_skill.py +++ b/neon_utils/skills/common_play_skill.py @@ -44,6 +44,8 @@ from enum import Enum, IntEnum from abc import ABC, abstractmethod from ovos_bus_client import Message +from ovos_utils.log import log_deprecation + from neon_utils.skills.neon_skill import NeonSkill from ovos_utils.skills.audioservice import AudioServiceInterface as AudioService @@ -85,6 +87,9 @@ class CommonPlaySkill(NeonSkill, ABC): is needed. """ def __init__(self, *args, **kwargs): + log_deprecation("This base class is deprecated. Implement " + "`ovos_workshop.skills.common_play." + "OVOSCommonPlaybackSkill`", "2.0.0") NeonSkill.__init__(self, *args, **kwargs) self.audioservice = None self.play_service_string = None diff --git a/neon_utils/skills/common_query_skill.py b/neon_utils/skills/common_query_skill.py index e8f1fd6a..b87d5b28 100644 --- a/neon_utils/skills/common_query_skill.py +++ b/neon_utils/skills/common_query_skill.py @@ -40,23 +40,19 @@ # See the License for the specific language governing permissions and # limitations under the License. -from enum import IntEnum -from abc import ABC, abstractmethod +from abc import abstractmethod +from os.path import dirname + +from ovos_workshop.decorators.layers import IntentLayers +from ovos_workshop.skills.common_query_skill import CQSMatchLevel, CQSVisualMatchLevel +from ovos_workshop.skills.common_query_skill import CommonQuerySkill as _CQS +from ovos_utils.file_utils import resolve_resource_file +from ovos_utils.log import log_deprecation from neon_utils.skills.neon_skill import NeonSkill -class CQSMatchLevel(IntEnum): - EXACT = 1 # Skill could find a specific answer for the question - CATEGORY = 2 # Skill could find an answer from a category in the query - GENERAL = 3 # The query could be processed as a general query - - -# Copy of CQSMatchLevel to use if the skill returns visual media -CQSVisualMatchLevel = IntEnum('CQSVisualMatchLevel', - [e.name for e in CQSMatchLevel]) - - def is_CQSVisualMatchLevel(match_level): + log_deprecation("This method is deprecated", "2.0.0") return isinstance(match_level, type(CQSVisualMatchLevel.EXACT)) @@ -64,10 +60,12 @@ def is_CQSVisualMatchLevel(match_level): def handles_visuals(platform): + log_deprecation("This method is deprecated", "2.0.0") return platform in VISUAL_DEVICES -class CommonQuerySkill(NeonSkill, ABC): +# TODO: Consider deprecation and implementing ovos_workshop directly +class CommonQuerySkill(NeonSkill, _CQS): """Question answering skills should be based on this class. The skill author needs to implement `CQS_match_query_phrase` returning an @@ -78,47 +76,36 @@ class CommonQuerySkill(NeonSkill, ABC): answers from several skills presenting the best one available. """ def __init__(self, *args, **kwargs): - NeonSkill.__init__(self, *args, **kwargs) + # these should probably be configurable + self.level_confidence = { + CQSMatchLevel.EXACT: 0.9, + CQSMatchLevel.CATEGORY: 0.6, + CQSMatchLevel.GENERAL: 0.5 + } + + # Manual init of OVOSSkill + self.private_settings = None + self._threads = [] + self._original_converse = self.converse + self.intent_layers = IntentLayers() + self.audio_service = None - def bind(self, bus): - """Overrides the default bind method of MycroftSkill. - - This registers messagebus handlers for the skill during startup - but is nothing the skill author needs to consider. - """ - if bus: - super().bind(bus) - self.add_event('question:query', self.__handle_question_query) - self.add_event('question:action', self.__handle_query_action) - - def __handle_question_query(self, message): - search_phrase = message.data["phrase"] + NeonSkill.__init__(self, *args, **kwargs) - # First, notify the requestor that we are attempting to handle - # (this extends a timeout while this skill looks for a match) - self.bus.emit(message.response({"phrase": search_phrase, - "skill_id": self.skill_id, - "searching": True})) - - # Now invoke the CQS handler to let the skill perform its search - result = self.CQS_match_query_phrase(search_phrase, message) - - if result: - match = result[0] - level = result[1] - answer = result[2] - callback = result[3] if len(result) > 3 else None - confidence = self.__calc_confidence(match, search_phrase, level) - self.bus.emit(message.response({"phrase": search_phrase, - "skill_id": self.skill_id, - "answer": answer, - "callback_data": callback, - "conf": confidence})) - else: - # Signal we are done (can't handle it) - self.bus.emit(message.response({"phrase": search_phrase, - "skill_id": self.skill_id, - "searching": False})) + noise_words_filepath = f"text/{self.lang}/noise_words.list" + default_res = f"{dirname(dirname(__file__))}/res/text/{self.lang}" \ + f"/noise_words.list" + noise_words_filename = \ + resolve_resource_file(noise_words_filepath, + config=self.config_core) or \ + resolve_resource_file(default_res, config=self.config_core) + + self._translated_noise_words = {} + if noise_words_filename: + with open(noise_words_filename) as f: + translated_noise_words = f.read().strip() + self._translated_noise_words[self.lang] = \ + translated_noise_words.split() def __calc_confidence(self, match, phrase, level): # Assume the more of the words that get consumed, the better the match @@ -156,6 +143,38 @@ def __handle_query_action(self, message): data = message.data.get("callback_data") # Invoke derived class to provide playback data self.CQS_action(phrase, data) + self.bus.emit(message.forward("mycroft.skill.handler.complete", + {"handler": "common_query"})) + + def __handle_question_query(self, message): + # Override ovos-workshop implementation that doesn't pass `message` + search_phrase = message.data["phrase"] + + # First, notify the requestor that we are attempting to handle + # (this extends a timeout while this skill looks for a match) + self.bus.emit(message.response({"phrase": search_phrase, + "skill_id": self.skill_id, + "searching": True})) + + # Now invoke the CQS handler to let the skill perform its search + result = self.CQS_match_query_phrase(search_phrase, message) + + if result: + match = result[0] + level = result[1] + answer = result[2] + callback = result[3] if len(result) > 3 else None + confidence = self.__calc_confidence(match, search_phrase, level) + self.bus.emit(message.response({"phrase": search_phrase, + "skill_id": self.skill_id, + "answer": answer, + "callback_data": callback, + "conf": confidence})) + else: + # Signal we are done (can't handle it) + self.bus.emit(message.response({"phrase": search_phrase, + "skill_id": self.skill_id, + "searching": False})) @abstractmethod def CQS_match_query_phrase(self, phrase, message): diff --git a/neon_utils/skills/mycroft_skill.py b/neon_utils/skills/mycroft_skill.py index ef7b2a38..9f0023ae 100644 --- a/neon_utils/skills/mycroft_skill.py +++ b/neon_utils/skills/mycroft_skill.py @@ -35,6 +35,7 @@ from typing import Optional from json_database import JsonStorage from ovos_bus_client.message import Message +from ovos_utils.log import log_deprecation from ovos_workshop.skills.mycroft_skill import MycroftSkill from ovos_utils.skills.settings import get_local_settings @@ -49,11 +50,21 @@ class PatchedMycroftSkill(MycroftSkill): def __init__(self, name=None, bus=None, *args, **kwargs): MycroftSkill.__init__(self, name, bus, *args, **kwargs) + log_deprecation("This base class is deprecated. Implement either" + "`NeonSkill` or `OVOSSkill`", "2.0.0") # TODO: Should below defaults be global config? # allow skills to specify timeout overrides per-skill self._speak_timeout = 30 self._get_response_timeout = 15 # 10 for listener, 5 for STT, then timeout + @property + def settings_path(self): + # TODO: Deprecate backwards-compat. wrapper after ovos-workshop 0.0.13 + try: + return super().settings_path + except AttributeError: + return super()._settings_path + @property def location(self): """ @@ -67,7 +78,7 @@ def _init_settings(self): Extends the default method to handle settingsmeta defaults locally """ super()._init_settings() - skill_settings = get_local_settings(self._settings_path) + skill_settings = get_local_settings(self.settings_path) settings_from_disk = dict(skill_settings) self.settings = dict_update_keys(skill_settings, self._read_default_settings()) @@ -75,11 +86,12 @@ def _init_settings(self): if isinstance(self.settings, JsonStorage): self.settings.store() else: - with open(self._settings_path, "w+") as f: + with open(self.settings_path, "w+") as f: json.dump(self.settings, f, indent=4) self._initial_settings = dict(self.settings) def _init_settings_manager(self): + # TODO: Same as upstream implementation? from ovos_workshop.settings import SkillSettingsManager self.settings_manager = SkillSettingsManager(self) diff --git a/neon_utils/skills/neon_fallback_skill.py b/neon_utils/skills/neon_fallback_skill.py index 5c16f3cf..d8f7b8f0 100644 --- a/neon_utils/skills/neon_fallback_skill.py +++ b/neon_utils/skills/neon_fallback_skill.py @@ -25,15 +25,16 @@ # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +from ovos_utils import LOG from neon_utils.skills.neon_skill import NeonSkill -from ovos_workshop.skills.ovos import OVOSSkill from ovos_utils.intents import IntentLayers from ovos_workshop.decorators.layers import IntentLayers -from ovos_workshop.skills.fallback import FallbackSkillV1 as FallbackSkill +from ovos_workshop.skills.fallback import FallbackSkillV1 -class NeonFallbackSkill(FallbackSkill, NeonSkill, OVOSSkill): +# TODO: Consider deprecation and implementing ovos_workshop directly +class NeonFallbackSkill(FallbackSkillV1, NeonSkill): """ Class that extends the NeonSkill and FallbackSkill classes to provide NeonSkill functionality to any Fallback skill subclassing this class. @@ -50,8 +51,33 @@ def __init__(self, *args, **kwargs): # list of fallback handlers registered by this instance self.instance_fallback_handlers = [] NeonSkill.__init__(self, *args, **kwargs) + LOG.debug(f"instance_handlers={self.instance_fallback_handlers}") + LOG.debug(f"class_handlers={FallbackSkillV1.fallback_handlers}") @property def fallback_config(self): # "skill_id": priority (int) overrides - return self.config_core["skills"].get("fallbacks", {}) \ No newline at end of file + return self.config_core["skills"].get("fallbacks", {}) + + @classmethod + def _register_fallback(cls, *args, **kwargs): + LOG.debug(f"register fallback") + FallbackSkillV1._register_fallback(*args, **kwargs) + + def _register_decorated(self): + # Explicitly overridden to ensure the correct super call is made + LOG.debug(f"Registering decorated methods for {self.skill_id}") + try: + FallbackSkillV1._register_decorated(self) + except Exception as e: + LOG.error(e) + NeonSkill._register_decorated(self) + from ovos_utils.skills import get_non_properties + for attr_name in get_non_properties(self): + method = getattr(self, attr_name) + if hasattr(method, 'fallback_priority'): + self.register_fallback(method, method.fallback_priority) + + def register_fallback(self, *args, **kwargs): + LOG.debug(f"Registering fallback handler for {self.skill_id}") + FallbackSkillV1.register_fallback(self, *args, **kwargs) diff --git a/neon_utils/skills/neon_skill.py b/neon_utils/skills/neon_skill.py index d9f09959..b35ceedf 100644 --- a/neon_utils/skills/neon_skill.py +++ b/neon_utils/skills/neon_skill.py @@ -29,6 +29,11 @@ import pathlib import pickle import os +import time +from threading import Event + +import yaml +import json from copy import deepcopy from functools import wraps @@ -39,15 +44,15 @@ from ovos_utils.gui import is_gui_connected from ovos_utils.skills import get_non_properties from ovos_utils.xdg_utils import xdg_cache_home -from ovos_utils.skills.settings import save_settings -from ovos_utils.log import deprecated +from ovos_utils.skills.settings import save_settings, get_local_settings +from ovos_utils.log import deprecated, log_deprecation from neon_utils.location_utils import to_system_time from neon_utils.logger import LOG -from neon_utils.message_utils import dig_for_message, resolve_message +from neon_utils.message_utils import dig_for_message, resolve_message, get_message_user from neon_utils.cache_utils import LRUCache -from neon_utils.skills.mycroft_skill import PatchedMycroftSkill from neon_utils.file_utils import resolve_neon_resource_file from neon_utils.user_utils import get_user_prefs +from ovos_workshop.skills.base import BaseSkill try: from neon_utils.mq_utils import send_mq_request @@ -72,9 +77,9 @@ CACHE_TIME_OFFSET = 24*60*60 # seconds in 24 hours -class NeonSkill(PatchedMycroftSkill): +class NeonSkill(BaseSkill): def __init__(self, name=None, bus=None, **kwargs): - PatchedMycroftSkill.__init__(self, name, bus, **kwargs) + BaseSkill.__init__(self, name, bus, **kwargs) self.cache_loc = os.path.join(xdg_cache_home(), "neon") os.makedirs(self.cache_loc, exist_ok=True) self.lru_cache = LRUCache() @@ -91,11 +96,32 @@ def __init__(self, name=None, bus=None, **kwargs): self._lang_detector = None self._translator = None + # TODO: Should below defaults be global config? + # allow skills to specify timeout overrides per-skill + self._speak_timeout = 30 + self._get_response_timeout = 15 # 10 for listener, 5 for STT, then timeout + def initialize(self): # schedule an event to load the cache on disk every CACHE_TIME_OFFSET seconds self.schedule_event(self._write_cache_on_disk, CACHE_TIME_OFFSET, name="neon.load_cache_on_disk") + @property + def settings_path(self): + # TODO: Deprecate backwards-compat. wrapper after ovos-workshop 0.0.13 + try: + return super().settings_path + except AttributeError: + return super()._settings_path + + @property + def resources(self): + # TODO: Deprecate backwards-compat. wrapper after ovos-workshop 0.0.13 + try: + return super().resources + except AttributeError: + return super()._resources + @property # @deprecated("Call `dateutil.tz.gettz` directly", "2.0.0") def sys_tz(self): @@ -504,3 +530,365 @@ def _register_decorated(self): if hasattr(method, 'chat_handler'): self._register_chat_handler(getattr(method, 'chat_handler'), method) + + @property + def location(self): + """ + Backwards-compatible location property. Returns core location config if + user location isn't specified. + """ + from neon_utils.configuration_utils import get_mycroft_compatible_location + return get_mycroft_compatible_location(get_user_prefs()["location"]) + + def _init_settings(self): + """ + Extends the default method to handle settingsmeta defaults locally + """ + from neon_utils.configuration_utils import dict_update_keys + super()._init_settings() + skill_settings = get_local_settings(self.settings_path) + settings_from_disk = dict(skill_settings) + self.settings = dict_update_keys(skill_settings, + self._read_default_settings()) + if self.settings != settings_from_disk: + if isinstance(self.settings, JsonStorage): + self.settings.store() + else: + with open(self.settings_path, "w+") as f: + json.dump(self.settings, f, indent=4) + self._initial_settings = dict(self.settings) + + def _init_settings_manager(self): + # TODO: Same as upstream implementation? + from ovos_workshop.settings import SkillSettingsManager + self.settings_manager = SkillSettingsManager(self) + + def _read_default_settings(self): + from neon_utils.configuration_utils import parse_skill_default_settings + yaml_path = os.path.join(self.root_dir, "settingsmeta.yml") + json_path = os.path.join(self.root_dir, "settingsmeta.json") + if os.path.isfile(yaml_path): + with open(yaml_path) as f: + self.settings_meta = yaml.safe_load(f) or dict() + elif os.path.isfile(json_path): + with open(json_path) as f: + self.settings_meta = json.load(f) + else: + return dict() + return parse_skill_default_settings(self.settings_meta) + + @resolve_message + def speak(self, utterance, expect_response=False, wait=False, meta=None, + message=None, private=False, speaker=None): + """ + Speak an utterance. + Arguments: + utterance (str): sentence mycroft should speak + expect_response (bool): set to True if Mycroft should listen for a response immediately after + speaking the utterance. + wait (bool): set to True to block while the text is being spoken. + meta: Information of what built the sentence. + message (Message): message associated with the input that this speak is associated with + private (bool): flag to indicate this message contains data that is private to the requesting user + speaker (dict): dict containing language or voice data to override user preference values + + """ + from neon_utils.signal_utils import check_for_signal, wait_for_signal_clear + # registers the skill as being active + meta = meta or {} + meta['skill'] = self.name + self.enclosure.register(self.name) + if utterance: + if not message: + LOG.debug('message is None.') + message = Message("speak") + if not speaker: + speaker = message.data.get("speaker", None) + + nick = get_message_user(message) + + if private and message.context.get("klat_data"): + LOG.debug("Private Message") + title = message.context["klat_data"].get("title") or \ + "!PRIVATE:Neon" + need_at_sign = True + if title.startswith("!PRIVATE"): + users = title.split(':')[1].split(',') + for idx, val in enumerate(users): + users[idx] = val.strip() + if len(users) == 2 and "Neon" in users: + need_at_sign = False + elif len(users) == 1: + need_at_sign = False + elif nick.startswith("guest"): + need_at_sign = False + if need_at_sign: + LOG.debug("Send message to private cid!") + utterance = f"@{nick} {utterance}" + + data = {"utterance": utterance, + "lang": self.lang, + "expect_response": expect_response, + "meta": meta, + "speaker": speaker, + "speak_ident": str(time.time())} + + if message.context.get("cc_data", {}).get("emit_response"): + msg_to_emit = message.reply("skills:execute.response", data, + {"destination": ["skills"], + "source": ["skills"]}) + else: + message.context.get("timing", {})["speech_start"] = time.time() + msg_to_emit = message.reply("speak", data, + {"destination": ["skills"], + "source": ["audio"]}) + LOG.debug(f"Skill speak! {data}") + LOG.debug(msg_to_emit.msg_type) + + if wait and check_for_signal("neon_speak_api", -1): + self.bus.wait_for_response(msg_to_emit, + msg_to_emit.data['speak_ident'], + self._speak_timeout) + else: + self.bus.emit(msg_to_emit) + if wait and not message.context.get("klat_data"): + LOG.debug("Using legacy isSpeaking signal") + wait_for_signal_clear('isSpeaking') + + else: + LOG.warning("Null utterance passed to speak") + LOG.warning(f"{self.name} | message={message}") + + @resolve_message + def speak_dialog(self, key, data=None, expect_response=False, wait=False, + message=None, private=False, speaker=None): + """ Speak a random sentence from a dialog file. + + Arguments: + :param key: dialog file key (e.g. "hello" to speak from the file + "locale/en-us/hello.dialog") + :param data: information used to populate key + :param expect_response: set to True if Mycroft should listen for a + response immediately after speaking. + :param wait: set to True to block while the text is being spoken. + :param message: associated message from request + :param private: private flag (server use only) + :param speaker: optional dict of speaker info to use + """ + data = data or {} + LOG.debug(f"data={data}") + if self.dialog_renderer: # TODO: Pass index (0) here to use non-random responses DM + to_speak = self.dialog_renderer.render(key, data) + else: + to_speak = key + self.speak(to_speak, + expect_response, message=message, private=private, + speaker=speaker, wait=wait, meta={'dialog': key, + 'data': data}) + + @resolve_message + def get_response(self, dialog: str = '', data: Optional[dict] = None, + validator=None, on_fail=None, num_retries: int = -1, + message: Optional[Message] = None) -> Optional[str]: + """ + Gets a response from a user. Speaks the passed dialog file or string + and then optionally plays a listening confirmation sound and + starts listening if in wake words mode. + Wraps the default Mycroft method to add support for multiple users and + running without a wake word. + + Arguments: + dialog (str): Optional dialog to speak to the user + data (dict): Data used to render the dialog + validator (any): Function with following signature + def validator(utterance): + return utterance != "red" + on_fail (any): Dialog or function returning literal string + to speak on invalid input. For example: + def on_fail(utterance): + return "nobody likes the color red, pick another" + num_retries (int): Times to ask user for input, -1 for infinite + NOTE: User can not respond and timeout or say "cancel" to stop + message (Message): Message associated with request + + Returns: + str: User's reply or None if timed out or canceled + """ + user = get_message_user(message) or "local" if message else "local" + data = data or {} + + def on_fail_default(utterance): + fail_data = data.copy() + fail_data['utterance'] = utterance + + if on_fail: + to_speak = on_fail + else: + to_speak = dialog + if self.dialog_renderer: + return self.dialog_renderer.render(to_speak, data) + else: + return to_speak + + def is_cancel(utterance): + return self.voc_match(utterance, 'cancel') + + def validator_default(utterance): + # accept anything except 'cancel' + return not is_cancel(utterance) + + on_fail_fn = on_fail if callable(on_fail) else on_fail_default + validator = validator or validator_default + + # Ensure we have a message to forward + message = message or dig_for_message() + if not message: + LOG.warning(f"Could not locate message associated with request!") + message = Message("get_response") + + # If skill has dialog, render the input + if self.dialog_renderer: + dialog = self.dialog_renderer.render(dialog, data) + + if dialog: + self.speak(dialog, wait=True, message=message, private=True) + self.bus.emit(message.forward('mycroft.mic.listen')) + return self._wait_response(is_cancel, validator, on_fail_fn, + num_retries, message, user) + + def _wait_response(self, is_cancel, validator, on_fail, num_retries, + message=None, user: str = None): + """ + Loop until a valid response is received from the user or the retry + limit is reached. + + Arguments: + is_cancel (callable): function checking cancel criteria + validator (callable): function checking for a valid response + on_fail (callable): function handling retries + message (Message): message associated with request + """ + user = user or "local" + num_fails = 0 + while True: + response = self.__get_response(user) + + if response is None: # No Response + # if nothing said, only prompt one more time + num_none_fails = 1 if num_retries < 0 else num_retries + LOG.debug(f"num_none_fails={num_none_fails}|" + f"num_fails={num_fails}") + if num_fails >= num_none_fails: + LOG.info("No user response") + return None + else: # Some response + # catch user saying 'cancel' + if is_cancel(response): + LOG.info("User cancelled") + return None + validated = validator(response) + # returns the validated value or the response + # (backwards compat) + if validated is not False and validated is not None: + LOG.debug(f"Returning validated response") + return response if validated is True else validated + LOG.debug(f"User response not validated: {response}") + # Unvalidated or no response + num_fails += 1 + if 0 < num_retries < num_fails: + LOG.info(f"Failed ({num_fails}) through all retries " + f"({num_retries})") + return None + + # Validation failed, retry + line = on_fail(response) + if line: + LOG.debug(f"Speaking failure dialog: {line}") + self.speak(line, wait=True, message=message, private=True) + + LOG.debug("Listen for another response") + msg = message.reply('mycroft.mic.listen') or \ + Message('mycroft.mic.listen', + context={"skill_id": self.skill_id}) + self.bus.emit(msg) + + def __get_response(self, user="local"): + """ + Helper to get a response from the user + + Arguments: + user (str): user associated with response + Returns: + str: user's response or None on a timeout + """ + event = Event() + + def converse(message): + resp_user = get_message_user(message) or "local" + if resp_user == user: + utterances = message.data.get("utterances") + converse.response = utterances[0] if utterances else None + event.set() + LOG.info(f"Got response: {converse.response}") + return True + LOG.debug(f"Ignoring input from: {resp_user}") + return False + + # install a temporary conversation handler + self.make_active() + converse.response = None + default_converse = self.converse + self.converse = converse + + if not event.wait(self._get_response_timeout): + LOG.warning("Timed out waiting for user response") + self.converse = default_converse + return converse.response + + # renamed in base class for naming consistency + # refactored to use new resource utils + def translate(self, text: str, data: Optional[dict] = None): + """ + Deprecated method for translating a dialog file. + use self.resources.render_dialog(text, data) instead + """ + log_deprecation("Use `resources.render_dialog`", "2.0.0") + return self.resources.render_dialog(text, data) + + # renamed in base class for naming consistency + # refactored to use new resource utils + def translate_namedvalues(self, name: str, delim: str = ','): + """ + Deprecated method for translating a name/value file. + use self.resources.load_named_value_filetext, data) instead + """ + log_deprecation("Use `resources.load_named_value_file`", "2.0.0") + return self.resources.load_named_value_file(name, delim) + + # renamed in base class for naming consistency + # refactored to use new resource utils + def translate_list(self, list_name: str, data: Optional[dict] = None): + """ + Deprecated method for translating a list. + use delf.resources.load_list_file(text, data) instead + """ + log_deprecation("Use `resources.load_list_file`", "2.0.0") + return self.resources.load_list_file(list_name, data) + + # renamed in base class for naming consistency + # refactored to use new resource utils + def translate_template(self, template_name: str, + data: Optional[dict] = None): + """ + Deprecated method for translating a template file + use self.resources.template_file(text, data) instead + """ + log_deprecation("Use `resources.template_file`", "2.0.0") + return self.resources.load_template_file(template_name, data) + + def init_dialog(self, root_directory: Optional[str] = None): + """ + DEPRECATED: use load_dialog_files instead + """ + log_deprecation("Use `load_dialog_files`", "2.0.0") + self.load_dialog_files(root_directory) diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 81a5ab84..7b7a2675 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -5,7 +5,7 @@ timezonefinder~=5.2 nltk~=3.5 pyyaml>=5.4,<7.0 ovos-lingua-franca~=0.4 -ovos_utils~=0.0.34,>=0.0.35 +ovos_utils~=0.0.35 geopy~=2.1 ovos-config~=0.0.9 ovos-workshop~=0.0.12 \ No newline at end of file diff --git a/tests/neon_skill_tests.py b/tests/neon_skill_tests.py index 7742999e..793d1859 100644 --- a/tests/neon_skill_tests.py +++ b/tests/neon_skill_tests.py @@ -37,16 +37,20 @@ from os.path import join from threading import Thread from time import sleep, time + +from ovos_bus_client import Message from ovos_utils.messagebus import FakeBus from mock import Mock +from neon_utils.skills import NeonSkill, CommonMessageSkill, CommonPlaySkill, CommonQuerySkill, NeonFallbackSkill sys.path.append(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) from neon_utils.cache_utils import LRUCache from neon_utils.signal_utils import check_for_signal - sys.path.append(os.path.dirname(os.path.realpath(__file__))) -from skills import * +from skills import (PatchedMycroftSkill, TestCMS, TestCPS, TestCQS, TestFBS, + TestPatchedSkill, TestInstructorSkill, TestChatSkill, + TestNeonSkill, TestKioskSkill, TestMycroftFallbackSkill) MycroftSkill = PatchedMycroftSkill ROOT_DIR = os.path.dirname(os.path.realpath(__file__)) @@ -95,28 +99,28 @@ class SkillObjectTests(unittest.TestCase): def test_common_message_skill_init(self): skill = create_skill(TestCMS) - self.assertIsInstance(skill, MycroftSkill) + # self.assertIsInstance(skill, MycroftSkill) self.assertIsInstance(skill, NeonSkill) self.assertIsInstance(skill, CommonMessageSkill) self.assertEqual(skill.name, "Test Common Message Skill") def test_common_play_skill_init(self): skill = create_skill(TestCPS) - self.assertIsInstance(skill, MycroftSkill) + # self.assertIsInstance(skill, MycroftSkill) self.assertIsInstance(skill, NeonSkill) self.assertIsInstance(skill, CommonPlaySkill) self.assertEqual(skill.name, "Test Common Play Skill") def test_common_query_skill_init(self): skill = create_skill(TestCQS) - self.assertIsInstance(skill, MycroftSkill) + # self.assertIsInstance(skill, MycroftSkill) self.assertIsInstance(skill, NeonSkill) self.assertIsInstance(skill, CommonQuerySkill) self.assertEqual(skill.name, "Test Common Query Skill") def test_fallback_skill_init(self): skill = create_skill(TestFBS) - self.assertIsInstance(skill, MycroftSkill) + # self.assertIsInstance(skill, MycroftSkill) self.assertIsInstance(skill, NeonSkill) self.assertIsInstance(skill, NeonFallbackSkill) # self.assertIsInstance(skill, FallbackSkill) @@ -124,7 +128,7 @@ def test_fallback_skill_init(self): def test_neon_skill_init(self): skill = create_skill(TestNeonSkill) - self.assertIsInstance(skill, MycroftSkill) + # self.assertIsInstance(skill, MycroftSkill) self.assertIsInstance(skill, NeonSkill) self.assertEqual(skill.name, "Test Neon Skill") @@ -149,9 +153,36 @@ def test_neon_skill_init(self): self.assertIsInstance(skill.preference_skill(), dict) self.assertEqual(skill.settings, skill.preference_skill()) self.assertIsInstance(skill.file_system.path, str) + # self.assertIsNotNone(skill.settings_meta) # self.assertEqual(skill.file_system.path, skill.settings_write_path) # self.assertNotEqual(os.path.basename(skill.file_system.path), # skill.name) + expected_methods = ['init_dialog', 'make_active', 'translate', + 'translate_namedvalues', 'translate_list', + 'translate_template', 'bind', + 'handle_settings_change', 'detach', + 'get_intro_message', 'converse', 'get_response', + 'ask_yesno', 'ask_selection', 'voc_match', + 'report_metric', 'send_email', + 'register_resting_screen', 'find_resource', + 'add_event', 'remove_event', 'register_intent', + 'register_intent_file', 'register_entity_file', + 'handle_enable_intent', 'handle_disable_intent', + 'disable_intent', 'enable_intent', 'set_context', + 'remove_context', 'handle_set_cross_context', + 'handle_remove_cross_context', + 'set_cross_skill_context', + 'remove_cross_skill_context', 'register_vocabulary', + 'register_regex', 'speak', 'speak_dialog', + 'load_dialog_files', 'load_data_files', + 'load_vocab_files', 'load_regex_files', 'stop', + 'shutdown', 'default_shutdown', 'schedule_event', + 'schedule_repeating_event', + 'update_scheduled_event', 'cancel_scheduled_event', + 'get_scheduled_event_status', + 'cancel_all_repeating_events'] + for method in expected_methods: + self.assertTrue(hasattr(skill, method), method) def test_patched_mycroft_skill_init(self): skill = create_skill(TestPatchedSkill) @@ -163,7 +194,7 @@ def test_patched_mycroft_skill_init(self): def test_instructor_skill_init(self): skill = create_skill(TestInstructorSkill) - self.assertIsInstance(skill, MycroftSkill) + # self.assertIsInstance(skill, MycroftSkill) self.assertEqual(skill.name, "Test Instructor Skill") @@ -179,7 +210,7 @@ def setUp(self) -> None: self.skill.event_scheduler.schedule_event = Mock() def test_kiosk_skill_init(self): - self.assertIsInstance(self.skill, MycroftSkill) + # self.assertIsInstance(self.skill, MycroftSkill) self.assertIsInstance(self.skill, NeonSkill) self.assertEqual(self.skill.greeting_dialog, 'greeting') self.assertEqual(self.skill.goodbye_dialog, 'goodbye') @@ -310,8 +341,8 @@ def handle_register(message): self.assertEqual(resp.msg_type, f'{test_message.msg_type}.response') self.assertEqual(resp.data, {'response': test_message.data['test_response']}) - self.assertEqual(resp.context, {'test': True, - 'skill_id': skill.skill_id}) + self.assertTrue(resp.context['test']) + self.assertEqual(resp.context['skill_id'], skill.skill_id) class PatchedMycroftSkillTests(unittest.TestCase): @@ -1047,6 +1078,10 @@ def setUpClass(cls) -> None: cls.skill.load_data_files() cls.skill.initialize() + cls.skill.config_core.setdefault('language', dict()) + cls.skill.config_core['language']['detection_module'] = "libretranslate_detection_plug" + cls.skill.config_core['language']['translation_module'] = "libretranslate_plug" + @classmethod def tearDownClass(cls) -> None: if os.path.isdir(cls.config_dir): @@ -1061,6 +1096,10 @@ def test_00_skill_init(self): self.assertIsInstance(self.skill.neon_core, bool) self.assertIsInstance(self.skill.skill_mode, str) self.assertIsInstance(self.skill.extension_time, int) + self.assertEqual(self.skill.config_core['language']['detection_module'], + "libretranslate_detection_plug") + self.assertEqual(self.skill.config_core['language']['translation_module'], + "libretranslate_plug") self.assertIsNotNone(self.skill.lang_detector) self.assertIsNotNone(self.skill.translator) diff --git a/tests/signal_util_tests.py b/tests/signal_util_tests.py index bf0c42a6..ede9e5da 100644 --- a/tests/signal_util_tests.py +++ b/tests/signal_util_tests.py @@ -130,9 +130,9 @@ def on_create(message): create_signal("test_signal") self.assertIsInstance(msg, Message) self.assertEqual(msg.data, {'signal_name': 'test_signal'}) - self.assertEqual(msg.context, - {'origin_module': 'tests.signal_util_tests', - 'origin_line': 130}) + self.assertEqual(msg.context['origin_module'], + 'tests.signal_util_tests') + self.assertEqual(msg.context['origin_line'], 130) def test_signal_utils_manager_available(self): TestSignalManager(self.test_bus) diff --git a/version.py b/version.py index 3809e822..0375d6a6 100644 --- a/version.py +++ b/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "1.6.2" +__version__ = "1.7.0"