Skip to content

Commit e57d628

Browse files
authored
feat/stop_per_session (#391)
* 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
1 parent 38176d7 commit e57d628

File tree

27 files changed

+988
-80
lines changed

27 files changed

+988
-80
lines changed

.github/workflows/coverage.yml

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,15 @@ jobs:
2626
pip install -e .[mycroft,deprecated]
2727
- name: Install test dependencies
2828
run: |
29-
pip install -r requirements/tests.txt
30-
pip install ./test/unittests/common_query/ovos_tskill_fakewiki
31-
pip install ./test/end2end/skill-ovos-hello-world
32-
pip install ./test/end2end/skill-ovos-schedule
33-
pip install ./test/end2end/skill-ovos-fallback-unknown
34-
pip install ./test/end2end/skill-ovos-fallback-unknownv1
35-
pip install ./test/end2end/skill-converse_test
29+
pip install -r requirements/tests.txt
30+
pip install ./test/unittests/common_query/ovos_tskill_fakewiki
31+
pip install ./test/end2end/skill-ovos-hello-world
32+
pip install ./test/end2end/skill-ovos-fallback-unknown
33+
pip install ./test/end2end/skill-ovos-fallback-unknownv1
34+
pip install ./test/end2end/skill-converse_test
35+
pip install ./test/end2end/skill-ovos-schedule
36+
pip install ./test/end2end/skill-new-stop
37+
pip install ./test/end2end/skill-old-stop
3638
- name: Generate coverage report
3739
run: |
3840
pytest --cov=ovos_core --cov-report xml test/unittests

.github/workflows/unit_tests.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ jobs:
5757
pip install ./test/end2end/skill-ovos-fallback-unknownv1
5858
pip install ./test/end2end/skill-converse_test
5959
pip install ./test/end2end/skill-ovos-schedule
60+
pip install ./test/end2end/skill-new-stop
61+
pip install ./test/end2end/skill-old-stop
6062
- name: Install core repo
6163
run: |
6264
pip install -e .[mycroft,deprecated]

ovos_core/intent_services/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from ovos_core.intent_services.adapt_service import AdaptService
2323
from ovos_core.intent_services.commonqa_service import CommonQAService
2424
from ovos_core.intent_services.converse_service import ConverseService
25+
from ovos_core.intent_services.stop_service import StopService
2526
from ovos_core.intent_services.fallback_service import FallbackService
2627
from ovos_core.intent_services.padacioso_service import PadaciosoService
2728
from ovos_core.transformers import MetadataTransformersService, UtteranceTransformersService
@@ -71,6 +72,7 @@ def __init__(self, bus):
7172
self.fallback = FallbackService(bus)
7273
self.converse = ConverseService(bus)
7374
self.common_qa = CommonQAService(bus)
75+
self.stop = StopService(bus)
7476
self.utterance_plugins = UtteranceTransformersService(bus, config=config)
7577
self.metadata_plugins = MetadataTransformersService(bus, config=config)
7678
# connection SessionManager to the bus,
@@ -216,6 +218,9 @@ def get_pipeline(self, skips=None, session=None):
216218

217219
matchers = {
218220
"converse": self.converse.converse_with_skills,
221+
"stop_high": self.stop.match_stop_high,
222+
"stop_medium": self.stop.match_stop_medium,
223+
"stop_low": self.stop.match_stop_low,
219224
"padatious_high": padatious_matcher.match_high,
220225
"padacioso_high": self.padacioso_service.match_high,
221226
"adapt": self.adapt_service.match_intent,
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
import os
2+
import re
3+
from os.path import dirname
4+
from threading import Event
5+
6+
import ovos_core.intent_services
7+
from ovos_bus_client.message import Message
8+
from ovos_bus_client.session import SessionManager
9+
from ovos_config.config import Configuration
10+
from ovos_utils import flatten_list
11+
from ovos_utils.bracket_expansion import expand_options
12+
from ovos_utils.log import LOG
13+
from ovos_utils.parse import match_one
14+
15+
16+
class StopService:
17+
"""Intent Service thats handles stopping skills."""
18+
19+
def __init__(self, bus):
20+
self.bus = bus
21+
self._voc_cache = {}
22+
self.load_resource_files()
23+
24+
def load_resource_files(self):
25+
base = f"{dirname(dirname(__file__))}/locale"
26+
for lang in os.listdir(base):
27+
lang2 = lang.split("-")[0].lower()
28+
self._voc_cache[lang2] = {}
29+
for f in os.listdir(f"{base}/{lang}"):
30+
with open(f"{base}/{lang}/{f}") as fi:
31+
lines = [expand_options(l) for l in fi.read().split("\n")
32+
if l.strip() and not l.startswith("#")]
33+
n = f.split(".", 1)[0]
34+
self._voc_cache[lang2][n] = flatten_list(lines)
35+
36+
@property
37+
def config(self):
38+
"""
39+
Returns:
40+
stop_config (dict): config for stop handling options
41+
"""
42+
return Configuration().get("skills", {}).get("stop") or {}
43+
44+
def get_active_skills(self, message=None):
45+
"""Active skill ids ordered by converse priority
46+
this represents the order in which stop will be called
47+
48+
Returns:
49+
active_skills (list): ordered list of skill_ids
50+
"""
51+
session = SessionManager.get(message)
52+
return [skill[0] for skill in session.active_skills]
53+
54+
def _collect_stop_skills(self, message):
55+
"""use the messagebus api to determine which skills can stop
56+
This includes all skills and external applications"""
57+
58+
want_stop = []
59+
skill_ids = []
60+
61+
active_skills = self.get_active_skills(message)
62+
63+
if not active_skills:
64+
return want_stop
65+
66+
event = Event()
67+
68+
def handle_ack(msg):
69+
nonlocal event
70+
skill_id = msg.data["skill_id"]
71+
72+
# validate the stop pong
73+
if all((skill_id not in want_stop,
74+
msg.data.get("can_handle", True),
75+
skill_id in active_skills)):
76+
want_stop.append(skill_id)
77+
78+
if skill_id not in skill_ids: # track which answer we got
79+
skill_ids.append(skill_id)
80+
81+
if all(s in skill_ids for s in active_skills):
82+
# all skills answered the ping!
83+
event.set()
84+
85+
self.bus.on("skill.stop.pong", handle_ack)
86+
87+
# ask skills if they can stop
88+
for skill_id in active_skills:
89+
self.bus.emit(message.forward(f"{skill_id}.stop.ping",
90+
{"skill_id": skill_id}))
91+
92+
# wait for all skills to acknowledge they can stop
93+
event.wait(timeout=0.5)
94+
95+
self.bus.remove("skill.stop.pong", handle_ack)
96+
return want_stop or active_skills
97+
98+
def stop_skill(self, skill_id, message):
99+
"""Tell a skill to stop anything it's doing,
100+
taking into account the message Session
101+
102+
Args:
103+
skill_id: skill to query.
104+
message (Message): message containing interaction info.
105+
106+
Returns:
107+
handled (bool): True if handled otherwise False.
108+
"""
109+
stop_msg = message.reply(f"{skill_id}.stop")
110+
result = self.bus.wait_for_response(stop_msg, f"{skill_id}.stop.response")
111+
if result and 'error' in result.data:
112+
error_msg = result.data['error']
113+
LOG.error(f"{skill_id}: {error_msg}")
114+
return False
115+
elif result is not None:
116+
return result.data.get('result', False)
117+
118+
def match_stop_high(self, utterances, lang, message):
119+
"""If utterance is an exact match for "stop" , run before intent stage
120+
121+
Args:
122+
utterances (list): list of utterances
123+
lang (string): 4 letter ISO language code
124+
message (Message): message to use to generate reply
125+
126+
Returns:
127+
IntentMatch if handled otherwise None.
128+
"""
129+
lang = lang.split("-")[0]
130+
if lang not in self._voc_cache:
131+
return None
132+
133+
# we call flatten in case someone is sending the old style list of tuples
134+
utterance = flatten_list(utterances)[0]
135+
136+
is_stop = self.voc_match(utterance, 'stop', exact=True, lang=lang)
137+
is_global_stop = self.voc_match(utterance, 'global_stop', exact=True, lang=lang) or \
138+
(is_stop and not len(self.get_active_skills(message)))
139+
140+
conf = 1.0
141+
142+
if is_global_stop:
143+
# emit a global stop, full stop anything OVOS is doing
144+
self.bus.emit(message.reply("mycroft.stop", {}))
145+
return ovos_core.intent_services.IntentMatch('Stop', None, {"conf": conf},
146+
None, utterance)
147+
148+
if is_stop:
149+
# check if any skill can stop
150+
for skill_id in self._collect_stop_skills(message):
151+
if self.stop_skill(skill_id, message):
152+
return ovos_core.intent_services.IntentMatch('Stop', None, {"conf": conf},
153+
skill_id, utterance)
154+
return None
155+
156+
def match_stop_medium(self, utterances, lang, message):
157+
""" if "stop" intent is in the utterance,
158+
but it contains additional words not in .intent files
159+
160+
Args:
161+
utterances (list): list of utterances
162+
lang (string): 4 letter ISO language code
163+
message (Message): message to use to generate reply
164+
165+
Returns:
166+
IntentMatch if handled otherwise None.
167+
"""
168+
lang = lang.split("-")[0]
169+
if lang not in self._voc_cache:
170+
return None
171+
172+
# we call flatten in case someone is sending the old style list of tuples
173+
utterance = flatten_list(utterances)[0]
174+
175+
is_stop = self.voc_match(utterance, 'stop', exact=False, lang=lang)
176+
if not is_stop:
177+
is_global_stop = self.voc_match(utterance, 'global_stop', exact=False, lang=lang) or \
178+
(is_stop and not len(self.get_active_skills(message)))
179+
if not is_global_stop:
180+
return None
181+
182+
return self.match_stop_low(utterances, lang, message)
183+
184+
def match_stop_low(self, utterances, lang, message):
185+
""" before fallback_low , fuzzy match stop intent
186+
187+
Args:
188+
utterances (list): list of utterances
189+
lang (string): 4 letter ISO language code
190+
message (Message): message to use to generate reply
191+
192+
Returns:
193+
IntentMatch if handled otherwise None.
194+
"""
195+
lang = lang.split("-")[0]
196+
if lang not in self._voc_cache:
197+
return None
198+
199+
# we call flatten in case someone is sending the old style list of tuples
200+
utterance = flatten_list(utterances)[0]
201+
202+
conf = match_one(utterance, self._voc_cache[lang]['stop'])[1]
203+
if len(self.get_active_skills(message)) > 0:
204+
conf += 0.1
205+
conf = round(min(conf, 1.0), 3)
206+
207+
if conf < self.config.get("min_conf", 0.5):
208+
return None
209+
210+
# check if any skill can stop
211+
for skill_id in self._collect_stop_skills(message):
212+
if self.stop_skill(skill_id, message):
213+
return ovos_core.intent_services.IntentMatch('Stop', None, {"conf": conf},
214+
skill_id, utterance)
215+
216+
# emit a global stop, full stop anything OVOS is doing
217+
self.bus.emit(message.reply("mycroft.stop", {}))
218+
return ovos_core.intent_services.IntentMatch('Stop', None, {"conf": conf},
219+
None, utterance)
220+
221+
def voc_match(self, utt: str, voc_filename: str, lang: str,
222+
exact: bool = False):
223+
"""
224+
Determine if the given utterance contains the vocabulary provided.
225+
226+
By default the method checks if the utterance contains the given vocab
227+
thereby allowing the user to say things like "yes, please" and still
228+
match against "Yes.voc" containing only "yes". An exact match can be
229+
requested.
230+
231+
The method first checks in the current Skill's .voc files and secondly
232+
in the "res/text" folder of mycroft-core. The result is cached to
233+
avoid hitting the disk each time the method is called.
234+
235+
Args:
236+
utt (str): Utterance to be tested
237+
voc_filename (str): Name of vocabulary file (e.g. 'yes' for
238+
'res/text/en-us/yes.voc')
239+
lang (str): Language code, defaults to self.lang
240+
exact (bool): Whether the vocab must exactly match the utterance
241+
242+
Returns:
243+
bool: True if the utterance has the given vocabulary it
244+
"""
245+
lang = lang.split("-")[0].lower()
246+
if lang not in self._voc_cache:
247+
return False
248+
249+
_vocs = self._voc_cache[lang].get(voc_filename) or []
250+
251+
if utt and _vocs:
252+
if exact:
253+
# Check for exact match
254+
return any(i.strip() == utt
255+
for i in _vocs)
256+
else:
257+
# Check for matches against complete words
258+
return any([re.match(r'.*\b' + i + r'\b.*', utt)
259+
for i in _vocs])
260+
return False
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
alles (stoppen|schließen|schliessen|beenden|abbrechen)
2+
(schließe|schließ|schliess|stop|stoppe|beende) alles
3+
(schließe|schließ|schliess|stop|stoppe|beende) alle (programme|anwendungen|prozesse|fenster|skills)
4+
(breche|brech) alles ab
5+
(breche|brech) (alle|) (programme|anwendungen|prozesse|fenster|skills) ab

ovos_core/locale/de-de/stop.intent

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
stop
2+
stopp
3+
genug (davon|)
4+
schluss
5+
schluß
6+
schließen
7+
ende
8+
beenden
9+
aufhören
10+
hör auf (damit|)
11+
abbrechen
12+
(brech|breche) ab
13+
abbruch
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
stop all
2+
end all
3+
terminate all
4+
cancel all
5+
finish all
6+
halt all
7+
abort all
8+
cease all
9+
stop everything
10+
end everything
11+
terminate everything
12+
cancel everything
13+
finish everything
14+
halt everything
15+
abort everything
16+
cease everything
17+
Stop everything now
18+
End all processes
19+
Terminate all operations
20+
Cancel all tasks
21+
Finish all activities
22+
Halt all activities immediately
23+
Abort all ongoing processes
24+
Cease all actions
25+
Stop all current tasks
26+
Terminate all running activities
27+
Cancel all pending operations
28+
Finish all open tasks
29+
Halt all ongoing processes
30+
Abort all running actions
31+
Cease all active activities

ovos_core/locale/en-us/stop.intent

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
stop
2+
stop doing that
3+
stop that
4+
Stop what you're doing
5+
Please stop that
6+
Can you stop now
7+
Stop performing that task
8+
Please halt the current action
9+
Stop the ongoing process
10+
Cease the current activity
11+
Please put an end to it
12+
Stop working on that
13+
Stop executing the current command
14+
Please terminate the current task
15+
Stop the current operation
16+
Cease the current action
17+
Please cancel the current task

ovos_core/locale/es-es/stop.intent

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# auto translated from en-us to es-es
2+
Para
3+
para de hacer eso
4+
detener

0 commit comments

Comments
 (0)