From 3716d6a86d9600c41b507e7c903b8fcdbb06a3a2 Mon Sep 17 00:00:00 2001 From: JarbasAi Date: Sat, 30 Dec 2023 21:45:58 +0000 Subject: [PATCH 1/2] feat/stop_per_session stop is a core functionality that should not be optional, it deserves it's dedicated disambiguation step in the pipeline since it is mission critical this commit deprecates the stop skill and makes it native core functionality stop now behaves like converse, it tries to stop active skills first by order of most recently used, if no skill stops then a global stop bus message is emmited (old / default behaviour) end2end tests --- .github/workflows/unit_tests.yml | 2 + ovos_core/intent_services/__init__.py | 5 + ovos_core/intent_services/stop_service.py | 260 +++++++++++ ovos_core/locale/de-de/global_stop.intent | 5 + ovos_core/locale/de-de/stop.intent | 13 + ovos_core/locale/en-us/global_stop.intent | 31 ++ ovos_core/locale/en-us/stop.intent | 17 + ovos_core/locale/es-es/stop.intent | 4 + ovos_core/locale/fr-fr/stop.intent | 8 + ovos_core/locale/it-it/stop.intent | 5 + ovos_core/locale/nl-be/stop.intent | 5 + ovos_core/locale/nl-nl/stop.intent | 4 + ovos_core/locale/pt-pt/stop.intent | 4 + ovos_core/locale/uk-ua/global_stop.intent | 15 + ovos_core/locale/uk-ua/stop.intent | 15 + test/end2end/session/test_stop.py | 430 ++++++++++++++++++ test/end2end/skill-converse_test/__init__.py | 19 +- test/end2end/skill-new-stop/__init__.py | 26 ++ .../locale/en-us/vocab/HelloWorldKeyword.voc | 1 + test/end2end/skill-new-stop/setup.py | 46 ++ test/end2end/skill-old-stop/__init__.py | 22 + .../locale/en-us/vocab/HelloWorldKeyword.voc | 1 + test/end2end/skill-old-stop/setup.py | 45 ++ .../skill-ovos-hello-world/__init__.py | 2 +- test/end2end/skill-ovos-schedule/__init__.py | 2 +- .../skills/test_common_query_skill.py | 65 +-- 26 files changed, 979 insertions(+), 73 deletions(-) create mode 100644 ovos_core/intent_services/stop_service.py create mode 100644 ovos_core/locale/de-de/global_stop.intent create mode 100644 ovos_core/locale/de-de/stop.intent create mode 100644 ovos_core/locale/en-us/global_stop.intent create mode 100644 ovos_core/locale/en-us/stop.intent create mode 100644 ovos_core/locale/es-es/stop.intent create mode 100644 ovos_core/locale/fr-fr/stop.intent create mode 100644 ovos_core/locale/it-it/stop.intent create mode 100644 ovos_core/locale/nl-be/stop.intent create mode 100644 ovos_core/locale/nl-nl/stop.intent create mode 100644 ovos_core/locale/pt-pt/stop.intent create mode 100644 ovos_core/locale/uk-ua/global_stop.intent create mode 100644 ovos_core/locale/uk-ua/stop.intent create mode 100644 test/end2end/session/test_stop.py create mode 100644 test/end2end/skill-new-stop/__init__.py create mode 100644 test/end2end/skill-new-stop/locale/en-us/vocab/HelloWorldKeyword.voc create mode 100755 test/end2end/skill-new-stop/setup.py create mode 100644 test/end2end/skill-old-stop/__init__.py create mode 100644 test/end2end/skill-old-stop/locale/en-us/vocab/HelloWorldKeyword.voc create mode 100755 test/end2end/skill-old-stop/setup.py diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 43686bbf0fcb..732babb3c4d8 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -57,6 +57,8 @@ jobs: pip install ./test/end2end/skill-ovos-fallback-unknownv1 pip install ./test/end2end/skill-converse_test pip install ./test/end2end/skill-ovos-schedule + pip install ./test/end2end/skill-new-stop + pip install ./test/end2end/skill-old-stop - name: Install core repo run: | pip install -e .[mycroft,deprecated] diff --git a/ovos_core/intent_services/__init__.py b/ovos_core/intent_services/__init__.py index 022a7d52e5bb..15d5a18d3e40 100644 --- a/ovos_core/intent_services/__init__.py +++ b/ovos_core/intent_services/__init__.py @@ -22,6 +22,7 @@ from ovos_core.intent_services.adapt_service import AdaptService from ovos_core.intent_services.commonqa_service import CommonQAService from ovos_core.intent_services.converse_service import ConverseService +from ovos_core.intent_services.stop_service import StopService from ovos_core.intent_services.fallback_service import FallbackService from ovos_core.intent_services.padacioso_service import PadaciosoService from ovos_core.transformers import MetadataTransformersService, UtteranceTransformersService @@ -71,6 +72,7 @@ def __init__(self, bus): self.fallback = FallbackService(bus) self.converse = ConverseService(bus) self.common_qa = CommonQAService(bus) + self.stop = StopService(bus) self.utterance_plugins = UtteranceTransformersService(bus, config=config) self.metadata_plugins = MetadataTransformersService(bus, config=config) # connection SessionManager to the bus, @@ -216,6 +218,9 @@ def get_pipeline(self, skips=None, session=None): matchers = { "converse": self.converse.converse_with_skills, + "stop_high": self.stop.match_stop_high, + "stop_medium": self.stop.match_stop_medium, + "stop_low": self.stop.match_stop_low, "padatious_high": padatious_matcher.match_high, "padacioso_high": self.padacioso_service.match_high, "adapt": self.adapt_service.match_intent, diff --git a/ovos_core/intent_services/stop_service.py b/ovos_core/intent_services/stop_service.py new file mode 100644 index 000000000000..324e6d77bbe1 --- /dev/null +++ b/ovos_core/intent_services/stop_service.py @@ -0,0 +1,260 @@ +import os +import re +from os.path import dirname +from threading import Event + +import ovos_core.intent_services +from ovos_bus_client.message import Message +from ovos_bus_client.session import SessionManager +from ovos_config.config import Configuration +from ovos_utils import flatten_list +from ovos_utils.bracket_expansion import expand_options +from ovos_utils.log import LOG +from ovos_utils.parse import match_one + + +class StopService: + """Intent Service thats handles stopping skills.""" + + def __init__(self, bus): + self.bus = bus + self._voc_cache = {} + self.load_resource_files() + + def load_resource_files(self): + base = f"{dirname(dirname(__file__))}/locale" + for lang in os.listdir(base): + lang2 = lang.split("-")[0].lower() + self._voc_cache[lang2] = {} + for f in os.listdir(f"{base}/{lang}"): + with open(f"{base}/{lang}/{f}") as fi: + lines = [expand_options(l) for l in fi.read().split("\n") + if l.strip() and not l.startswith("#")] + n = f.split(".", 1)[0] + self._voc_cache[lang2][n] = flatten_list(lines) + + @property + def config(self): + """ + Returns: + stop_config (dict): config for stop handling options + """ + return Configuration().get("skills", {}).get("stop") or {} + + def get_active_skills(self, message=None): + """Active skill ids ordered by converse priority + this represents the order in which stop will be called + + Returns: + active_skills (list): ordered list of skill_ids + """ + session = SessionManager.get(message) + return [skill[0] for skill in session.active_skills] + + def _collect_stop_skills(self, message): + """use the messagebus api to determine which skills can stop + This includes all skills and external applications""" + + want_stop = [] + skill_ids = [] + + active_skills = self.get_active_skills(message) + + if not active_skills: + return want_stop + + event = Event() + + def handle_ack(msg): + nonlocal event + skill_id = msg.data["skill_id"] + + # validate the stop pong + if all((skill_id not in want_stop, + msg.data.get("can_handle", True), + skill_id in active_skills)): + want_stop.append(skill_id) + + if skill_id not in skill_ids: # track which answer we got + skill_ids.append(skill_id) + + if all(s in skill_ids for s in active_skills): + # all skills answered the ping! + event.set() + + self.bus.on("skill.stop.pong", handle_ack) + + # ask skills if they can stop + for skill_id in active_skills: + self.bus.emit(message.forward(f"{skill_id}.stop.ping", + {"skill_id": skill_id})) + + # wait for all skills to acknowledge they can stop + event.wait(timeout=0.5) + + self.bus.remove("skill.stop.pong", handle_ack) + return want_stop or active_skills + + def stop_skill(self, skill_id, message): + """Tell a skill to stop anything it's doing, + taking into account the message Session + + Args: + skill_id: skill to query. + message (Message): message containing interaction info. + + Returns: + handled (bool): True if handled otherwise False. + """ + stop_msg = message.reply(f"{skill_id}.stop") + result = self.bus.wait_for_response(stop_msg, f"{skill_id}.stop.response") + if result and 'error' in result.data: + error_msg = result.data['error'] + LOG.error(f"{skill_id}: {error_msg}") + return False + elif result is not None: + return result.data.get('result', False) + + def match_stop_high(self, utterances, lang, message): + """If utterance is an exact match for "stop" , run before intent stage + + Args: + utterances (list): list of utterances + lang (string): 4 letter ISO language code + message (Message): message to use to generate reply + + Returns: + IntentMatch if handled otherwise None. + """ + lang = lang.split("-")[0] + if lang not in self._voc_cache: + return None + + # we call flatten in case someone is sending the old style list of tuples + utterance = flatten_list(utterances)[0] + + is_stop = self.voc_match(utterance, 'stop', exact=True, lang=lang) + is_global_stop = self.voc_match(utterance, 'global_stop', exact=True, lang=lang) or \ + (is_stop and not len(self.get_active_skills(message))) + + conf = 1.0 + + if is_global_stop: + # emit a global stop, full stop anything OVOS is doing + self.bus.emit(message.reply("mycroft.stop", {})) + return ovos_core.intent_services.IntentMatch('Stop', None, {"conf": conf}, + None, utterance) + + if is_stop: + # check if any skill can stop + for skill_id in self._collect_stop_skills(message): + if self.stop_skill(skill_id, message): + return ovos_core.intent_services.IntentMatch('Stop', None, {"conf": conf}, + skill_id, utterance) + return None + + def match_stop_medium(self, utterances, lang, message): + """ if "stop" intent is in the utterance, + but it contains additional words not in .intent files + + Args: + utterances (list): list of utterances + lang (string): 4 letter ISO language code + message (Message): message to use to generate reply + + Returns: + IntentMatch if handled otherwise None. + """ + lang = lang.split("-")[0] + if lang not in self._voc_cache: + return None + + # we call flatten in case someone is sending the old style list of tuples + utterance = flatten_list(utterances)[0] + + is_stop = self.voc_match(utterance, 'stop', exact=False, lang=lang) + if not is_stop: + is_global_stop = self.voc_match(utterance, 'global_stop', exact=False, lang=lang) or \ + (is_stop and not len(self.get_active_skills(message))) + if not is_global_stop: + return None + + return self.match_stop_low(utterances, lang, message) + + def match_stop_low(self, utterances, lang, message): + """ before fallback_low , fuzzy match stop intent + + Args: + utterances (list): list of utterances + lang (string): 4 letter ISO language code + message (Message): message to use to generate reply + + Returns: + IntentMatch if handled otherwise None. + """ + lang = lang.split("-")[0] + if lang not in self._voc_cache: + return None + + # we call flatten in case someone is sending the old style list of tuples + utterance = flatten_list(utterances)[0] + + conf = match_one(utterance, self._voc_cache[lang]['stop'])[1] + if len(self.get_active_skills(message)) > 0: + conf += 0.1 + conf = round(min(conf, 1.0), 3) + + if conf < self.config.get("min_conf", 0.5): + return None + + # check if any skill can stop + for skill_id in self._collect_stop_skills(message): + if self.stop_skill(skill_id, message): + return ovos_core.intent_services.IntentMatch('Stop', None, {"conf": conf}, + skill_id, utterance) + + # emit a global stop, full stop anything OVOS is doing + self.bus.emit(message.reply("mycroft.stop", {})) + return ovos_core.intent_services.IntentMatch('Stop', None, {"conf": conf}, + None, utterance) + + def voc_match(self, utt: str, voc_filename: str, lang: str, + exact: bool = False): + """ + Determine if the given utterance contains the vocabulary provided. + + By default the method checks if the utterance contains the given vocab + thereby allowing the user to say things like "yes, please" and still + match against "Yes.voc" containing only "yes". An exact match can be + requested. + + The method first checks in the current Skill's .voc files and secondly + in the "res/text" folder of mycroft-core. The result is cached to + avoid hitting the disk each time the method is called. + + Args: + utt (str): Utterance to be tested + voc_filename (str): Name of vocabulary file (e.g. 'yes' for + 'res/text/en-us/yes.voc') + lang (str): Language code, defaults to self.lang + exact (bool): Whether the vocab must exactly match the utterance + + Returns: + bool: True if the utterance has the given vocabulary it + """ + lang = lang.split("-")[0].lower() + if lang not in self._voc_cache: + return False + + _vocs = self._voc_cache[lang].get(voc_filename) or [] + + if utt and _vocs: + if exact: + # Check for exact match + return any(i.strip() == utt + for i in _vocs) + else: + # Check for matches against complete words + return any([re.match(r'.*\b' + i + r'\b.*', utt) + for i in _vocs]) + return False diff --git a/ovos_core/locale/de-de/global_stop.intent b/ovos_core/locale/de-de/global_stop.intent new file mode 100644 index 000000000000..ca3d880c506d --- /dev/null +++ b/ovos_core/locale/de-de/global_stop.intent @@ -0,0 +1,5 @@ +alles (stoppen|schließen|schliessen|beenden|abbrechen) +(schließe|schließ|schliess|stop|stoppe|beende) alles +(schließe|schließ|schliess|stop|stoppe|beende) alle (programme|anwendungen|prozesse|fenster|skills) +(breche|brech) alles ab +(breche|brech) (alle|) (programme|anwendungen|prozesse|fenster|skills) ab diff --git a/ovos_core/locale/de-de/stop.intent b/ovos_core/locale/de-de/stop.intent new file mode 100644 index 000000000000..b82f057835f1 --- /dev/null +++ b/ovos_core/locale/de-de/stop.intent @@ -0,0 +1,13 @@ +stop +stopp +genug (davon|) +schluss +schluß +schließen +ende +beenden +aufhören +hör auf (damit|) +abbrechen +(brech|breche) ab +abbruch diff --git a/ovos_core/locale/en-us/global_stop.intent b/ovos_core/locale/en-us/global_stop.intent new file mode 100644 index 000000000000..9186999a140d --- /dev/null +++ b/ovos_core/locale/en-us/global_stop.intent @@ -0,0 +1,31 @@ +stop all +end all +terminate all +cancel all +finish all +halt all +abort all +cease all +stop everything +end everything +terminate everything +cancel everything +finish everything +halt everything +abort everything +cease everything +Stop everything now +End all processes +Terminate all operations +Cancel all tasks +Finish all activities +Halt all activities immediately +Abort all ongoing processes +Cease all actions +Stop all current tasks +Terminate all running activities +Cancel all pending operations +Finish all open tasks +Halt all ongoing processes +Abort all running actions +Cease all active activities \ No newline at end of file diff --git a/ovos_core/locale/en-us/stop.intent b/ovos_core/locale/en-us/stop.intent new file mode 100644 index 000000000000..6bcfbda7182f --- /dev/null +++ b/ovos_core/locale/en-us/stop.intent @@ -0,0 +1,17 @@ +stop +stop doing that +stop that +Stop what you're doing +Please stop that +Can you stop now +Stop performing that task +Please halt the current action +Stop the ongoing process +Cease the current activity +Please put an end to it +Stop working on that +Stop executing the current command +Please terminate the current task +Stop the current operation +Cease the current action +Please cancel the current task \ No newline at end of file diff --git a/ovos_core/locale/es-es/stop.intent b/ovos_core/locale/es-es/stop.intent new file mode 100644 index 000000000000..27d6fe23ceed --- /dev/null +++ b/ovos_core/locale/es-es/stop.intent @@ -0,0 +1,4 @@ +# auto translated from en-us to es-es +Para +para de hacer eso +detener diff --git a/ovos_core/locale/fr-fr/stop.intent b/ovos_core/locale/fr-fr/stop.intent new file mode 100644 index 000000000000..2d8c8f249e3a --- /dev/null +++ b/ovos_core/locale/fr-fr/stop.intent @@ -0,0 +1,8 @@ +# auto translated from en-us to fr-fr and fixed +arrête ça +arrête toi +arrête +stop +tais toi +chut +arrête de parler diff --git a/ovos_core/locale/it-it/stop.intent b/ovos_core/locale/it-it/stop.intent new file mode 100644 index 000000000000..ad1cd6c07bea --- /dev/null +++ b/ovos_core/locale/it-it/stop.intent @@ -0,0 +1,5 @@ +basta +smettila +fermati +stop +piantala diff --git a/ovos_core/locale/nl-be/stop.intent b/ovos_core/locale/nl-be/stop.intent new file mode 100644 index 000000000000..eff0d0577f71 --- /dev/null +++ b/ovos_core/locale/nl-be/stop.intent @@ -0,0 +1,5 @@ +stop +bol het af +stop ermee +stoppen +stop subiet diff --git a/ovos_core/locale/nl-nl/stop.intent b/ovos_core/locale/nl-nl/stop.intent new file mode 100644 index 000000000000..fe6e9244209e --- /dev/null +++ b/ovos_core/locale/nl-nl/stop.intent @@ -0,0 +1,4 @@ +stop +stop daarmee +stop ermee +stoppen diff --git a/ovos_core/locale/pt-pt/stop.intent b/ovos_core/locale/pt-pt/stop.intent new file mode 100644 index 000000000000..7de62ed84007 --- /dev/null +++ b/ovos_core/locale/pt-pt/stop.intent @@ -0,0 +1,4 @@ +# auto translated from en-us to pt-pt +Pare com isso +Pare +Pare de fazer isso diff --git a/ovos_core/locale/uk-ua/global_stop.intent b/ovos_core/locale/uk-ua/global_stop.intent new file mode 100644 index 000000000000..c72dd20f5c59 --- /dev/null +++ b/ovos_core/locale/uk-ua/global_stop.intent @@ -0,0 +1,15 @@ +зупиніть все +завершіть все +припиніть все +скасуйте все +зупиніть все зараз +припиніть всі процеси +припиніть всі операції +скасуйте всі завдання +завершіть всі дії +завершіть всі запущені дії +скасуйте всі очікуючі операції +завершіть всі відкриті завдання +зупиніть всі запущені процеси +припиніть всі запущені дії +припиніть всі активні дії \ No newline at end of file diff --git a/ovos_core/locale/uk-ua/stop.intent b/ovos_core/locale/uk-ua/stop.intent new file mode 100644 index 000000000000..f2f8618eef38 --- /dev/null +++ b/ovos_core/locale/uk-ua/stop.intent @@ -0,0 +1,15 @@ +зупинити +Зупиніть те, що ви робите +Будь ласка, зупиніть це +Чи можете ви зупинити зараз? +Зупиніть виконання цього завдання +Будь ласка, припиніть поточну дію +Зупиніть поточний процес +Припиніть поточну діяльність +Завершіть це +Зупиніть роботу над цим +Зупиніть виконання поточної команди +Будь ласка, припиніть поточне завдання +Зупиніть поточну операцію +Припиніть поточну дію +Будь ласка, скасуйте поточне завдання \ No newline at end of file diff --git a/test/end2end/session/test_stop.py b/test/end2end/session/test_stop.py new file mode 100644 index 000000000000..7ba53c52456a --- /dev/null +++ b/test/end2end/session/test_stop.py @@ -0,0 +1,430 @@ +import time +from time import sleep +from unittest import TestCase +from ovos_bus_client.message import Message +from ovos_bus_client.session import SessionManager, Session + +from ..minicroft import get_minicroft + + +class TestSessions(TestCase): + + def setUp(self): + self.skill_id = "skill-old-stop.openvoiceos" + self.new_skill_id = "skill-new-stop.openvoiceos" + self.core = get_minicroft([self.skill_id, self.new_skill_id]) + + def tearDown(self) -> None: + self.core.stop() + + def test_old_stop(self): + SessionManager.sessions = {} + SessionManager.default_session = SessionManager.sessions["default"] = Session("default") + SessionManager.default_session.lang = "en-us" + + messages = [] + + def new_msg(msg): + nonlocal messages + m = Message.deserialize(msg) + if m.msg_type in ["ovos.skills.settings_changed"]: + return # skip these, only happen in 1st run + messages.append(m) + print(len(messages), msg) + + def wait_for_n_messages(n): + nonlocal messages + t = time.time() + while len(messages) < n: + sleep(0.1) + if time.time() - t > 10: + raise RuntimeError("did not get the number of expected messages under 10 seconds") + + self.core.bus.on("message", new_msg) + + sess = Session("123", + pipeline=[ + "stop_high", + "converse", + "padatious_high", + "adapt", + "common_qa", + "fallback_high", + "stop_medium", + "padatious_medium", + "fallback_medium", + "padatious_low", + "fallback_low" + ]) + + # old style global stop, even if nothing active + def skill_not_active(): + nonlocal messages, sess + utt = Message("recognizer_loop:utterance", + {"utterances": ["stop"]}, + {"session": sess.serialize()}) + self.core.bus.emit(utt) + + # confirm all expected messages are sent + expected_messages = [ + "recognizer_loop:utterance", + "mycroft.stop", + # global stop trigger + f"{self.skill_id}.stop", # internal, @killable_events + f"{self.skill_id}.stop.response", # skill reporting nothing to stop + f"{self.new_skill_id}.stop", # internal, @killable_events + f"{self.new_skill_id}.stop.response", # skill reporting nothing to stop + + # sanity check in test skill that method was indeed called + "enclosure.active_skill", + "speak" # "utterance":"old stop called" + + ] + + wait_for_n_messages(len(expected_messages)) + + mtypes = [m.msg_type for m in messages] + for m in expected_messages: + self.assertTrue(m in mtypes) + + # sanity check stop triggered + speak = messages[-1] + self.assertEqual(speak.data["utterance"], "old stop called") + + messages = [] + + # get the skill in active list + def old_world(): + nonlocal messages, sess + utt = Message("recognizer_loop:utterance", + {"utterances": ["old world"]}, + {"session": sess.serialize()}) + self.core.bus.emit(utt) + + # confirm all expected messages are sent + expected_messages = [ + "recognizer_loop:utterance", + # skill selected + "intent.service.skills.activated", + f"{self.skill_id}.activate", + f"{self.skill_id}:OldWorldIntent", + # skill executing + "mycroft.skill.handler.start", + "enclosure.active_skill", + "speak", + "mycroft.skill.handler.complete" + ] + + wait_for_n_messages(len(expected_messages)) + + mtypes = [m.msg_type for m in messages] + for m in expected_messages: + self.assertTrue(m in mtypes) + + # sanity check correct intent triggered + speak = messages[6] + self.assertEqual(speak.data["utterance"], "hello world") + + # test that active skills list has been updated + sess = Session.deserialize(messages[-1].context["session"]) + self.assertEqual(sess.active_skills[0][0], self.skill_id) + + messages = [] + + # stop should now go over active skills list + def skill_active(): + nonlocal messages, sess + utt = Message("recognizer_loop:utterance", + {"utterances": ["stop"]}, + {"session": sess.serialize()}) + self.core.bus.emit(utt) + + # confirm all expected messages are sent + expected_messages = [ + "recognizer_loop:utterance", + + # stop_high + f"{self.skill_id}.stop.ping", # check if active skill wants to stop + "skill.stop.pong", # "can_handle":true + f"{self.skill_id}.stop", # skill specific stop trigger + f"{self.skill_id}.stop.response", # skill fails to stop (old style) + + # rest of pipeline + f"{self.skill_id}.converse.ping", # converse + "skill.converse.pong", + "mycroft.skills.fallback", + "mycroft.skill.handler.start", + "mycroft.skill.handler.complete", + "mycroft.skills.fallback.response", + + # stop medium + f"{self.skill_id}.stop.ping", + "skill.stop.pong", + f"{self.skill_id}.stop", # skill specific stop trigger + f"{self.skill_id}.stop.response", # skill fails to stop (old style) + + # stop fallback + "mycroft.stop", # global stop for backwards compat + f"{self.skill_id}.stop", + f"{self.skill_id}.stop.response", # apparently fails to stop (old style) + + # test in skill that global stop was called + "enclosure.active_skill", + "speak", # "utterance":"stop" + + # report old-style stop handled event + "mycroft.stop.handled", # {"by":"skill:skill-old-stop.openvoiceos"} + + # old style unwanted side effects (global stop is global) + f"{self.new_skill_id}.stop", + f"{self.new_skill_id}.stop.response", + "enclosure.active_skill", # other test skill also speaks + "speak" # "utterance":"old stop called" + ] + + wait_for_n_messages(len(expected_messages)) + + mtypes = [m.msg_type for m in messages] + for m in expected_messages: + self.assertTrue(m in mtypes) + + # confirm all skills self.stop methods called + speak = messages[-1] + self.assertEqual(speak.data["utterance"], "old stop called") + speak = messages[-6] + self.assertEqual(speak.data["utterance"], "stop") + + # confirm "skill-old-stop" was the one that reported success + handler = messages[-5] + self.assertEqual(handler.msg_type, "mycroft.stop.handled") + self.assertEqual(handler.data["by"], f"skill:{self.skill_id}") + + messages = [] + + # nothing to stop + skill_not_active() + + # get the skill in active list + old_world() + skill_active() + + def test_new_stop(self): + SessionManager.sessions = {} + SessionManager.default_session = SessionManager.sessions["default"] = Session("default") + SessionManager.default_session.lang = "en-us" + + messages = [] + + def new_msg(msg): + nonlocal messages + m = Message.deserialize(msg) + if m.msg_type in ["ovos.skills.settings_changed"]: + return # skip these, only happen in 1st run + messages.append(m) + print(len(messages), msg) + + def wait_for_n_messages(n): + nonlocal messages + t = time.time() + while len(messages) < n: + sleep(0.1) + if time.time() - t > 10: + raise RuntimeError("did not get the number of expected messages under 10 seconds") + + self.core.bus.on("message", new_msg) + + sess = Session("123", + pipeline=[ + "stop_high", + "converse", + "padatious_high", + "adapt", + "common_qa", + "fallback_high", + "stop_medium", + "padatious_medium", + "fallback_medium", + "padatious_low", + "fallback_low" + ]) + + # old style global stop, even if nothing active + def skill_not_active(): + nonlocal messages, sess + utt = Message("recognizer_loop:utterance", + {"utterances": ["stop"]}, + {"session": sess.serialize()}) + self.core.bus.emit(utt) + + # confirm all expected messages are sent + expected_messages = [ + "recognizer_loop:utterance", + "mycroft.stop", + # global stop trigger + f"{self.skill_id}.stop", # internal, @killable_events + f"{self.skill_id}.stop.response", # skill reporting nothing to stop + f"{self.new_skill_id}.stop", # internal, @killable_events + f"{self.new_skill_id}.stop.response", # skill reporting nothing to stop + + # sanity check in test skill that method was indeed called + "enclosure.active_skill", + "speak" # "utterance":"old stop called" + + ] + + wait_for_n_messages(len(expected_messages)) + + mtypes = [m.msg_type for m in messages] + for m in expected_messages: + self.assertTrue(m in mtypes) + + # sanity check stop triggered + speak = messages[-1] + self.assertEqual(speak.data["utterance"], "old stop called") + + messages = [] + + # get the skill in active list + def new_world(): + nonlocal messages, sess + utt = Message("recognizer_loop:utterance", + {"utterances": ["new world"]}, + {"session": sess.serialize()}) + self.core.bus.emit(utt) + + # confirm all expected messages are sent + expected_messages = [ + "recognizer_loop:utterance", + # skill selected + "intent.service.skills.activated", + f"{self.new_skill_id}.activate", + f"{self.new_skill_id}:NewWorldIntent", + # skill executing + "mycroft.skill.handler.start", + "enclosure.active_skill", + "speak", + "mycroft.skill.handler.complete" + ] + + wait_for_n_messages(len(expected_messages)) + + mtypes = [m.msg_type for m in messages] + for m in expected_messages: + self.assertTrue(m in mtypes) + + # sanity check correct intent triggered + speak = messages[6] + self.assertEqual(speak.data["utterance"], "hello world") + + # test that active skills list has been updated + sess = Session.deserialize(messages[-1].context["session"]) + self.assertEqual(sess.active_skills[0][0], self.new_skill_id) + + messages = [] + + # stop should now go over active skills list + def skill_active(): + nonlocal messages, sess + utt = Message("recognizer_loop:utterance", + {"utterances": ["stop"]}, + {"session": sess.serialize()}) + self.core.bus.emit(utt) + + # confirm all expected messages are sent + expected_messages = [ + "recognizer_loop:utterance", + + # stop_high + f"{self.new_skill_id}.stop.ping", # check if active skill wants to stop + "skill.stop.pong", # "can_handle":true + f"{self.new_skill_id}.stop", # skill specific stop trigger + + # test session specific stop was called + "enclosure.active_skill", + "speak", # "utterance":"stop 123" + + f"{self.new_skill_id}.stop.response", # skill reports it stopped (new style) + "intent.service.skills.activated", # pipeline match reports skill_id + f"{self.new_skill_id}.activate", # can now converse + ] + + wait_for_n_messages(len(expected_messages)) + + mtypes = [m.msg_type for m in messages] + for m in expected_messages: + self.assertTrue(m in mtypes) + + # confirm all skills self.stop methods called + speak = messages[-4] + self.assertEqual(speak.data["utterance"], "stop 123") + + # confirm "skill-new-stop" was the one that reported success + handler = messages[-3] + self.assertEqual(handler.msg_type, f"{self.new_skill_id}.stop.response") + self.assertEqual(handler.data["result"], True) + + messages = [] + + def skill_already_stop(): + nonlocal messages, sess + utt = Message("recognizer_loop:utterance", + {"utterances": ["stop"]}, + {"session": sess.serialize()}) + self.core.bus.emit(utt) + + # confirm all expected messages are sent + expected_messages = [ + "recognizer_loop:utterance", + + # stop_high + f"{self.new_skill_id}.stop.ping", # check if active skill wants to stop + "skill.stop.pong", # "can_handle":true + f"{self.new_skill_id}.stop", # skill specific stop trigger + f"{self.new_skill_id}.stop.response", # dont want to stop (new style) + + # rest of pipeline + "skill-new-stop.openvoiceos.converse.ping", + "skill.converse.pong", + "mycroft.skills.fallback", + "mycroft.skill.handler.start", + "mycroft.skill.handler.complete", + "mycroft.skills.fallback.response", + + # stop low + "skill-new-stop.openvoiceos.stop.ping", + "skill.stop.pong", + f"{self.new_skill_id}.stop", # skill specific stop trigger + f"{self.new_skill_id}.stop.response", # dont want to stop (new style) + + # global stop fallback + "mycroft.stop", + f"{self.skill_id}.stop", # skill specific stop trigger + f"{self.skill_id}.stop.response", # old style, never stops + f"{self.new_skill_id}.stop", # skill specific stop trigger + f"{self.skill_id}.stop.response", # dont want to stop (new style) + + # check the global stop handlers are called + "enclosure.active_skill", + "speak", # "utterance":"old stop called" + ] + + wait_for_n_messages(len(expected_messages)) + + mtypes = [m.msg_type for m in messages] + for m in expected_messages: + self.assertTrue(m in mtypes) + + # confirm self.stop method called + speak = messages[-1] + self.assertEqual(speak.data["utterance"], "old stop called") + + messages = [] + + # nothing to stop + skill_not_active() + + # get the skill in active list + new_world() + skill_active() # reports success + + skill_already_stop() # reports failure diff --git a/test/end2end/skill-converse_test/__init__.py b/test/end2end/skill-converse_test/__init__.py index e0dde686429e..3d0d5f39dd77 100644 --- a/test/end2end/skill-converse_test/__init__.py +++ b/test/end2end/skill-converse_test/__init__.py @@ -1,7 +1,6 @@ from time import sleep -from mycroft.skills import intent_file_handler -from ovos_workshop.decorators import killable_intent +from ovos_workshop.decorators import killable_intent, intent_handler from ovos_workshop.skills.ovos import OVOSSkill @@ -26,12 +25,12 @@ def do_activate(self, message): def do_deactivate(self, message): self.deactivate() - @intent_file_handler("converse_on.intent") + @intent_handler("converse_on.intent") def handle_converse_on(self, message): self._converse = True self.speak("on") - @intent_file_handler("converse_off.intent") + @intent_handler("converse_off.intent") def handle_converse_off(self, message): self._converse = False self.speak("off") @@ -39,17 +38,17 @@ def handle_converse_off(self, message): def handle_intent_aborted(self): self.speak("I am dead") - @intent_file_handler("test_get_response.intent") + @intent_handler("test_get_response.intent") def handle_test_get_response(self, message): ans = self.get_response("get", num_retries=1) self.speak(ans or "ERROR") - @intent_file_handler("test_get_response3.intent") + @intent_handler("test_get_response3.intent") def handle_test_get_response3(self, message): ans = self.get_response(num_retries=3) self.speak(ans or "ERROR") - @intent_file_handler("test_get_response_cascade.intent") + @intent_handler("test_get_response_cascade.intent") def handle_test_get_response_cascade(self, message): quit = False self.items = [] @@ -63,7 +62,7 @@ def handle_test_get_response_cascade(self, message): self.bus.emit(message.forward("skill_items", {"items": self.items})) @killable_intent(callback=handle_intent_aborted) - @intent_file_handler("test.intent") + @intent_handler("test.intent") def handle_test_abort_intent(self, message): self.stop_called = False self.my_special_var = "changed" @@ -71,7 +70,7 @@ def handle_test_abort_intent(self, message): sleep(1) self.speak("still here") - @intent_file_handler("test2.intent") + @intent_handler("test2.intent") @killable_intent(callback=handle_intent_aborted) def handle_test_get_response_intent(self, message): self.stop_called = False @@ -82,7 +81,7 @@ def handle_test_get_response_intent(self, message): self.speak("question aborted") @killable_intent(msg="my.own.abort.msg", callback=handle_intent_aborted) - @intent_file_handler("test3.intent") + @intent_handler("test3.intent") def handle_test_msg_intent(self, message): self.stop_called = False if self.my_special_var != "default": diff --git a/test/end2end/skill-new-stop/__init__.py b/test/end2end/skill-new-stop/__init__.py new file mode 100644 index 000000000000..ef5a13408fde --- /dev/null +++ b/test/end2end/skill-new-stop/__init__.py @@ -0,0 +1,26 @@ +from ovos_workshop.intents import IntentBuilder +from ovos_workshop.decorators import intent_handler +from ovos_workshop.skills import OVOSSkill +from ovos_bus_client.session import SessionManager, Session + + +class NewStopSkill(OVOSSkill): + + def initialize(self): + self.active = [] + + @intent_handler(IntentBuilder("NewWorldIntent").require("HelloWorldKeyword")) + def handle_hello_world_intent(self, message): + self.speak_dialog("hello.world") + sess = SessionManager.get(message) + self.active.append(sess.session_id) + + def stop_session(self, sess: Session): + if sess.session_id in self.active: + self.speak(f"stop {sess.session_id}") + self.active.remove(sess.session_id) + return True + return False + + def stop(self): + self.speak("old stop called") diff --git a/test/end2end/skill-new-stop/locale/en-us/vocab/HelloWorldKeyword.voc b/test/end2end/skill-new-stop/locale/en-us/vocab/HelloWorldKeyword.voc new file mode 100644 index 000000000000..18091e85f31d --- /dev/null +++ b/test/end2end/skill-new-stop/locale/en-us/vocab/HelloWorldKeyword.voc @@ -0,0 +1 @@ +new world \ No newline at end of file diff --git a/test/end2end/skill-new-stop/setup.py b/test/end2end/skill-new-stop/setup.py new file mode 100755 index 000000000000..a60431638ca2 --- /dev/null +++ b/test/end2end/skill-new-stop/setup.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +from os import walk, path + +from setuptools import setup + +URL = "https://github.com/OpenVoiceOS/skill-new-stop" +SKILL_CLAZZ = "NewStopSkill" # needs to match __init__.py class name + +# below derived from github url to ensure standard skill_id +SKILL_AUTHOR, SKILL_NAME = URL.split(".com/")[-1].split("/") +SKILL_PKG = SKILL_NAME.lower().replace('-', '_') +PLUGIN_ENTRY_POINT = f'{SKILL_NAME.lower()}.{SKILL_AUTHOR.lower()}={SKILL_PKG}:{SKILL_CLAZZ}' + + +# skill_id=package_name:SkillClass + + +def find_resource_files(): + resource_base_dirs = ("locale", "ui", "vocab", "dialog", "regex", "skill") + base_dir = path.dirname(__file__) + package_data = ["*.json"] + for res in resource_base_dirs: + if path.isdir(path.join(base_dir, res)): + for (directory, _, files) in walk(path.join(base_dir, res)): + if files: + package_data.append( + path.join(directory.replace(base_dir, "").lstrip('/'), + '*')) + return package_data + + +setup( + name="skill-new-stop", + version="0.0.0", + long_description="test", + description='OVOS test plugin', + author_email='jarbasai@mailfence.com', + license='Apache-2.0', + package_dir={SKILL_PKG: ""}, + package_data={SKILL_PKG: find_resource_files()}, + packages=[SKILL_PKG], + include_package_data=True, + install_requires=["ovos-workshop>=0.0.16a8"], + keywords='ovos skill plugin', + entry_points={'ovos.plugin.skill': PLUGIN_ENTRY_POINT} +) diff --git a/test/end2end/skill-old-stop/__init__.py b/test/end2end/skill-old-stop/__init__.py new file mode 100644 index 000000000000..2ccbced0bb4a --- /dev/null +++ b/test/end2end/skill-old-stop/__init__.py @@ -0,0 +1,22 @@ +from ovos_workshop.intents import IntentBuilder +from ovos_workshop.decorators import intent_handler +from ovos_workshop.skills import OVOSSkill +from ovos_bus_client.session import SessionManager, Session + + +class OldStopSkill(OVOSSkill): + + def initialize(self): + self.active = False + + @intent_handler(IntentBuilder("OldWorldIntent").require("HelloWorldKeyword")) + def handle_hello_world_intent(self, message): + self.speak_dialog("hello.world") + self.active = True + + def stop(self): + if self.active: + self.speak("stop") + self.active = False + return True + return False diff --git a/test/end2end/skill-old-stop/locale/en-us/vocab/HelloWorldKeyword.voc b/test/end2end/skill-old-stop/locale/en-us/vocab/HelloWorldKeyword.voc new file mode 100644 index 000000000000..ba449ed42791 --- /dev/null +++ b/test/end2end/skill-old-stop/locale/en-us/vocab/HelloWorldKeyword.voc @@ -0,0 +1 @@ +old world \ No newline at end of file diff --git a/test/end2end/skill-old-stop/setup.py b/test/end2end/skill-old-stop/setup.py new file mode 100755 index 000000000000..cf4491512d86 --- /dev/null +++ b/test/end2end/skill-old-stop/setup.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +from os import walk, path + +from setuptools import setup + +URL = "https://github.com/OpenVoiceOS/skill-old-stop" +SKILL_CLAZZ = "OldStopSkill" # needs to match __init__.py class name + +# below derived from github url to ensure standard skill_id +SKILL_AUTHOR, SKILL_NAME = URL.split(".com/")[-1].split("/") +SKILL_PKG = SKILL_NAME.lower().replace('-', '_') +PLUGIN_ENTRY_POINT = f'{SKILL_NAME.lower()}.{SKILL_AUTHOR.lower()}={SKILL_PKG}:{SKILL_CLAZZ}' + + +# skill_id=package_name:SkillClass + + +def find_resource_files(): + resource_base_dirs = ("locale", "ui", "vocab", "dialog", "regex", "skill") + base_dir = path.dirname(__file__) + package_data = ["*.json"] + for res in resource_base_dirs: + if path.isdir(path.join(base_dir, res)): + for (directory, _, files) in walk(path.join(base_dir, res)): + if files: + package_data.append( + path.join(directory.replace(base_dir, "").lstrip('/'), + '*')) + return package_data + + +setup( + name="skill-old-stop", + version="0.0.0", + long_description="test", + description='OVOS test plugin', + author_email='jarbasai@mailfence.com', + license='Apache-2.0', + package_dir={SKILL_PKG: ""}, + package_data={SKILL_PKG: find_resource_files()}, + packages=[SKILL_PKG], + include_package_data=True, + keywords='ovos skill plugin', + entry_points={'ovos.plugin.skill': PLUGIN_ENTRY_POINT} +) diff --git a/test/end2end/skill-ovos-hello-world/__init__.py b/test/end2end/skill-ovos-hello-world/__init__.py index df0104baa837..b8df1a9e9fbf 100644 --- a/test/end2end/skill-ovos-hello-world/__init__.py +++ b/test/end2end/skill-ovos-hello-world/__init__.py @@ -1,4 +1,4 @@ -from ovos_utils.intents import IntentBuilder +from ovos_workshop.intents import IntentBuilder from ovos_workshop.decorators import intent_handler from ovos_workshop.skills import OVOSSkill diff --git a/test/end2end/skill-ovos-schedule/__init__.py b/test/end2end/skill-ovos-schedule/__init__.py index f19464791d6d..60fcb3ab2f34 100644 --- a/test/end2end/skill-ovos-schedule/__init__.py +++ b/test/end2end/skill-ovos-schedule/__init__.py @@ -1,4 +1,4 @@ -from ovos_utils.intents import IntentBuilder +from ovos_workshop.intents import IntentBuilder from ovos_workshop.decorators import intent_handler from ovos_workshop.skills import OVOSSkill diff --git a/test/unittests/skills/test_common_query_skill.py b/test/unittests/skills/test_common_query_skill.py index cdaf45dd0a13..a048cc56fff4 100644 --- a/test/unittests/skills/test_common_query_skill.py +++ b/test/unittests/skills/test_common_query_skill.py @@ -1,9 +1,8 @@ from unittest import TestCase, mock from ovos_bus_client.message import Message -from mycroft.skills.common_query_skill import (CommonQuerySkill, CQSMatchLevel, - CQSVisualMatchLevel, - handles_visuals) + +from mycroft.skills.common_query_skill import (CommonQuerySkill, handles_visuals) from test.unittests.mocks import AnyCallable @@ -30,7 +29,7 @@ def test_handles_visuals(self): def test_common_test_skill_action(self): """Test that the optional action is triggered.""" - query_action = self.bus.on.call_args_list[-1][0][1] + query_action = self.bus.on.call_args_list[-2][0][1] query_action(Message('query:action', data={ 'phrase': 'What\'s the meaning of life', 'skill_id': 'asdf'})) @@ -42,65 +41,9 @@ def test_common_test_skill_action(self): 'What\'s the meaning of life', {}) -class TestCommonQueryMatching(TestCase): - """Tests for CQS_match_query_phrase.""" - def setUp(self): - self.skill = CQSTest() - self.bus = mock.Mock(name='bus') - self.skill.bind(self.bus) - self.skill.config_core = {'enclosure': {'platform': 'mycroft_mark_1'}} - # Get the method for handle_query_phrase - self.query_phrase = self.bus.on.call_args_list[-2][0][1] - - def test_failing_match_query_phrase(self): - self.skill.CQS_match_query_phrase.return_value = None - self.query_phrase(Message('question:query', - data={ - 'phrase': 'What\'s the meaning of life' - })) - - # Check that the skill replied that it was searching - extension = self.bus.emit.call_args_list[-2][0][0] - self.assertEqual(extension.data['phrase'], - 'What\'s the meaning of life') - self.assertEqual(extension.data['skill_id'], self.skill.skill_id) - self.assertEqual(extension.data['searching'], True) - - # Assert that the skill reported that it couldn't find the phrase - response = self.bus.emit.call_args_list[-1][0][0] - self.assertEqual(response.data['phrase'], - 'What\'s the meaning of life') - - self.assertEqual(response.data['skill_id'], self.skill.skill_id) - self.assertEqual(response.data['searching'], False) - - def test_successful_match_query_phrase(self): - self.skill.CQS_match_query_phrase.return_value = ( - 'What\'s the meaning of life', CQSMatchLevel.EXACT, '42') - - self.query_phrase(Message('question:query', - data={ - 'phrase': 'What\'s the meaning of life' - })) - - # Check that the skill replied that it was searching - extension = self.bus.emit.call_args_list[-2][0][0] - self.assertEqual(extension.data['phrase'], - 'What\'s the meaning of life') - self.assertEqual(extension.data['skill_id'], self.skill.skill_id) - self.assertEqual(extension.data['searching'], True) - - # Assert that the skill responds with answer and confidence level - response = self.bus.emit.call_args_list[-1][0][0] - self.assertEqual(response.data['phrase'], - 'What\'s the meaning of life') - self.assertEqual(response.data['skill_id'], self.skill.skill_id) - self.assertEqual(response.data['answer'], '42') - self.assertTrue(response.data['conf'] >= 1.0) - - class CQSTest(CommonQuerySkill): """Simple skill for testing the CommonQuerySkill""" + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.CQS_match_query_phrase = mock.Mock(name='match_phrase') From 3e4d6391cd354a1218831bbd5b81088e1c02d0fa Mon Sep 17 00:00:00 2001 From: JarbasAi Date: Sat, 13 Jan 2024 11:12:29 +0000 Subject: [PATCH 2/2] . --- .github/workflows/coverage.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index f5e95e5836fd..690c8879c5b1 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -26,13 +26,15 @@ jobs: pip install -e .[mycroft,deprecated] - name: Install test dependencies run: | - pip install -r requirements/tests.txt - pip install ./test/unittests/common_query/ovos_tskill_fakewiki - pip install ./test/end2end/skill-ovos-hello-world - pip install ./test/end2end/skill-ovos-schedule - pip install ./test/end2end/skill-ovos-fallback-unknown - pip install ./test/end2end/skill-ovos-fallback-unknownv1 - pip install ./test/end2end/skill-converse_test + pip install -r requirements/tests.txt + pip install ./test/unittests/common_query/ovos_tskill_fakewiki + pip install ./test/end2end/skill-ovos-hello-world + pip install ./test/end2end/skill-ovos-fallback-unknown + pip install ./test/end2end/skill-ovos-fallback-unknownv1 + pip install ./test/end2end/skill-converse_test + pip install ./test/end2end/skill-ovos-schedule + pip install ./test/end2end/skill-new-stop + pip install ./test/end2end/skill-old-stop - name: Generate coverage report run: | pytest --cov=ovos_core --cov-report xml test/unittests