Skip to content

Commit

Permalink
feat/ovos.utterance.handled (#478)
Browse files Browse the repository at this point in the history
* feat/ovos.utterance.handled

emit a bus message when utterance is handled to unambigously identify when handling was completed. "ovos.utterance.handled"

allows clients such as hivemind to know when to stop expecting more "speak" events

also implements TODO for cancel sound, sound does not exist and does not play by default
  • Loading branch information
JarbasAl authored May 28, 2024
1 parent 6d82c2d commit 3cf75ed
Show file tree
Hide file tree
Showing 20 changed files with 372 additions and 309 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/unit_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
strategy:
max-parallel: 3
matrix:
python-version: [ 3.8, 3.9]
python-version: [3.9]
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
Expand Down
4 changes: 2 additions & 2 deletions mycroft/skills/common_play_skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from enum import Enum, IntEnum
from abc import ABC, abstractmethod
from ovos_bus_client.message import Message
from ovos_workshop.skills.mycroft_skill import MycroftSkill
from ovos_workshop.skills import OVOSSkill
from ovos_bus_client.apis.ocp import ClassicAudioServiceInterface as AudioService


Expand Down Expand Up @@ -45,7 +45,7 @@ class CPSTrackStatus(IntEnum):
END_OF_MEDIA = 90 # playback finished, is the default state when CPS loads


class CommonPlaySkill(MycroftSkill, ABC):
class CommonPlaySkill(OVOSSkill, ABC):
""" To integrate with the common play infrastructure of Mycroft
skills should use this base class and override the two methods
`CPS_match_query_phrase` (for checking if the skill can play the
Expand Down
106 changes: 55 additions & 51 deletions ovos_core/intent_services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,13 +298,22 @@ def _emit_match_message(self, match: IntentMatch, message: Message):

# Launch skill if not handled by the match function
if match.intent_type:
# keep all original message.data and update with intent
# match, mycroft-core only keeps "utterances"
# keep all original message.data and update with intent match
data = dict(message.data)
data.update(match.intent_data)
# NOTE: message.reply to ensure correct message destination
reply = message.reply(match.intent_type, data)
self.bus.emit(reply)

def send_cancel_event(self, message):
LOG.info("utterance canceled, cancel_word:" + message.context.get("cancel_word"))
# play dedicated cancel sound
sound = Configuration().get('sounds', {}).get('cancel', "snd/cancel.mp3")
# NOTE: message.reply to ensure correct message destination
self.bus.emit(message.reply('mycroft.audio.play_sound', {"uri": sound}))
self.bus.emit(message.reply("ovos.utterance.cancelled"))
self.bus.emit(message.reply("ovos.utterance.handled"))

def handle_utterance(self, message: Message):
"""Main entrypoint for handling user utterances
Expand Down Expand Up @@ -332,60 +341,53 @@ def handle_utterance(self, message: Message):
Args:
message (Message): The messagebus data
"""
try:
# Get utterance utterance_plugins additional context
message = self._handle_transformers(message)

# Get utterance utterance_plugins additional context
message = self._handle_transformers(message)
if message.context.get("canceled"):
self.send_cancel_event(message)
return

if message.context.get("canceled"):
# TODO - play dedicated sound
LOG.info("utterance canceled, cancel_word:" + message.context.get("cancel_word"))
self.bus.emit(message.reply("ovos.utterance.cancelled"))
return
# tag language of this utterance
lang = self.disambiguate_lang(message)

# tag language of this utterance
lang = self.disambiguate_lang(message)
try:
setup_locale(lang)
except Exception as e:
LOG.exception(f"Failed to set lingua_franca default lang to {lang}")

try:
setup_locale(lang)
except Exception as e:
LOG.exception(f"Failed to set lingua_franca default lang to {lang}")

utterances = message.data.get('utterances', [])

stopwatch = Stopwatch()

# get session
sess = self._validate_session(message, lang)
message.context["session"] = sess.serialize()

# match
match = None
with stopwatch:
# Loop through the matching functions until a match is found.
for match_func in self.get_pipeline(session=sess):
match = match_func(utterances, lang, message)
if match:
try:
self._emit_match_message(match, message)
break
except:
LOG.exception(f"{match_func} returned an invalid match")
LOG.debug(f"no match from {match_func}")
else:
# Nothing was able to handle the intent
# Ask politely for forgiveness for failing in this vital task
self.send_complete_intent_failure(message)
utterances = message.data.get('utterances', [])

LOG.debug(f"intent matching took: {stopwatch.time}")
stopwatch = Stopwatch()

# sync any changes made to the default session, eg by ConverseService
if sess.session_id == "default":
SessionManager.sync(message)
return match, message.context, stopwatch
# get session
sess = self._validate_session(message, lang)
message.context["session"] = sess.serialize()

# match
match = None
with stopwatch:
# Loop through the matching functions until a match is found.
for match_func in self.get_pipeline(session=sess):
match = match_func(utterances, lang, message)
if match:
try:
self._emit_match_message(match, message)
break
except:
LOG.exception(f"{match_func} returned an invalid match")
LOG.debug(f"no match from {match_func}")
else:
# Nothing was able to handle the intent
# Ask politely for forgiveness for failing in this vital task
self.send_complete_intent_failure(message)

except Exception as err:
LOG.exception(err)
LOG.debug(f"intent matching took: {stopwatch.time}")

# sync any changes made to the default session, eg by ConverseService
if sess.session_id == "default":
SessionManager.sync(message)
return match, message.context, stopwatch

def send_complete_intent_failure(self, message):
"""Send a message that no skill could handle the utterance.
Expand All @@ -394,8 +396,10 @@ def send_complete_intent_failure(self, message):
message (Message): original message to forward from
"""
sound = Configuration().get('sounds', {}).get('error', "snd/error.mp3")
self.bus.emit(message.forward('mycroft.audio.play_sound', {"uri": sound}))
self.bus.emit(message.forward('complete_intent_failure'))
# NOTE: message.reply to ensure correct message destination
self.bus.emit(message.reply('mycroft.audio.play_sound', {"uri": sound}))
self.bus.emit(message.reply('complete_intent_failure'))
self.bus.emit(message.reply("ovos.utterance.handled"))

def handle_register_vocab(self, message):
"""Register adapt vocabulary.
Expand Down
9 changes: 5 additions & 4 deletions ovos_core/intent_services/commonqa_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,11 @@ def match(self, utterances: str, lang: str, message: Message):
message.data["utterance"] = utterance
answered, skill_id = self.handle_question(message)
if answered:
match = ovos_core.intent_services.IntentMatch('CommonQuery',
None, {},
skill_id,
utterance)
match = ovos_core.intent_services.IntentMatch(intent_service='CommonQuery',
intent_type="ovos.utterance.handled", # emit instead of intent message
intent_data={},
skill_id=skill_id,
utterance=utterance)
break
return match

Expand Down
9 changes: 8 additions & 1 deletion ovos_core/intent_services/converse_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,14 +312,21 @@ def converse_with_skills(self, utterances, lang, message):
Returns:
IntentMatch if handled otherwise None.
"""
session = SessionManager.get(message)

# we call flatten in case someone is sending the old style list of tuples
utterances = flatten_list(utterances)
# filter allowed skills
self._check_converse_timeout(message)
# check if any skill wants to handle utterance
for skill_id in self._collect_converse_skills(message):
if self.converse(utterances, skill_id, lang, message):
return ovos_core.intent_services.IntentMatch('Converse', None, None, skill_id, utterances[0])
state = session.utterance_states.get(skill_id, UtteranceState.INTENT)
return ovos_core.intent_services.IntentMatch(intent_service='Converse',
intent_type="ovos.utterance.handled" if state != UtteranceState.RESPONSE else None, # emit instead of intent message
intent_data={},
skill_id=skill_id,
utterance=utterances[0])
return None

def handle_get_response_enable(self, message):
Expand Down
6 changes: 5 additions & 1 deletion ovos_core/intent_services/fallback_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,11 @@ def _fallback_range(self, utterances, lang, message, fb_range):
for skill_id, prio in sorted_handlers:
result = self.attempt_fallback(utterances, skill_id, lang, message)
if result:
return ovos_core.intent_services.IntentMatch('Fallback', None, {}, skill_id, utterances[0])
return ovos_core.intent_services.IntentMatch(intent_service='Fallback',
intent_type=None,
intent_data={},
skill_id=skill_id,
utterance=utterances[0])
return None

def high_prio(self, utterances, lang, message):
Expand Down
77 changes: 27 additions & 50 deletions ovos_core/intent_services/ocp_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,21 +175,21 @@ def register_ocp_api_events(self):
"""
Register messagebus handlers for OCP events
"""
self.bus.on("ovos.common_play.search", self.handle_search_query)
self.bus.on("ovos.common_play.play_search", self.handle_play_search)
self.bus.on('ovos.common_play.status.response', self.handle_player_state_update)
self.bus.on('ovos.common_play.track.state', self.handle_track_state_update)
self.bus.on('ovos.common_play.SEI.get.response', self.handle_get_SEIs)

self.bus.on('ovos.common_play.register_keyword', self.handle_skill_keyword_register)
self.bus.on('ovos.common_play.deregister_keyword', self.handle_skill_keyword_deregister)
self.bus.on('ovos.common_play.announce', self.handle_skill_register)

self.bus.on("mycroft.audio.playing_track", self._handle_legacy_audio_start)
self.bus.on("mycroft.audio.queue_end", self._handle_legacy_audio_end)
self.bus.on("mycroft.audio.service.pause", self._handle_legacy_audio_pause)
self.bus.on("mycroft.audio.service.resume", self._handle_legacy_audio_resume)
self.bus.on("mycroft.audio.service.stop", self._handle_legacy_audio_stop)
self.add_event("ovos.common_play.search", self.handle_search_query)
self.add_event("ovos.common_play.play_search", self.handle_play_search)
self.add_event('ovos.common_play.status.response', self.handle_player_state_update)
self.add_event('ovos.common_play.track.state', self.handle_track_state_update)
self.add_event('ovos.common_play.SEI.get.response', self.handle_get_SEIs)

self.add_event('ovos.common_play.register_keyword', self.handle_skill_keyword_register)
self.add_event('ovos.common_play.deregister_keyword', self.handle_skill_keyword_deregister)
self.add_event('ovos.common_play.announce', self.handle_skill_register)

self.add_event("mycroft.audio.playing_track", self._handle_legacy_audio_start)
self.add_event("mycroft.audio.queue_end", self._handle_legacy_audio_end)
self.add_event("mycroft.audio.service.pause", self._handle_legacy_audio_pause)
self.add_event("mycroft.audio.service.resume", self._handle_legacy_audio_resume)
self.add_event("mycroft.audio.service.stop", self._handle_legacy_audio_stop)
self.bus.emit(Message("ovos.common_play.status")) # sync player state on launch

def register_ocp_intents(self):
Expand All @@ -204,17 +204,17 @@ def register_ocp_intents(self):
self.intent_matchers[lang].add_intent(
intent_name.replace(".intent", ""), samples)

self.bus.on("ocp:play", self.handle_play_intent)
self.bus.on("ocp:play_favorites", self.handle_play_favorites_intent)
self.bus.on("ocp:open", self.handle_open_intent)
self.bus.on("ocp:next", self.handle_next_intent)
self.bus.on("ocp:prev", self.handle_prev_intent)
self.bus.on("ocp:pause", self.handle_pause_intent)
self.bus.on("ocp:resume", self.handle_resume_intent)
self.bus.on("ocp:media_stop", self.handle_stop_intent)
self.bus.on("ocp:search_error", self.handle_search_error_intent)
self.bus.on("ocp:like_song", self.handle_like_intent)
self.bus.on("ocp:legacy_cps", self.handle_legacy_cps)
self.add_event("ocp:play", self.handle_play_intent, is_intent=True)
self.add_event("ocp:play_favorites", self.handle_play_favorites_intent, is_intent=True)
self.add_event("ocp:open", self.handle_open_intent, is_intent=True)
self.add_event("ocp:next", self.handle_next_intent, is_intent=True)
self.add_event("ocp:prev", self.handle_prev_intent, is_intent=True)
self.add_event("ocp:pause", self.handle_pause_intent, is_intent=True)
self.add_event("ocp:resume", self.handle_resume_intent, is_intent=True)
self.add_event("ocp:media_stop", self.handle_stop_intent, is_intent=True)
self.add_event("ocp:search_error", self.handle_search_error_intent, is_intent=True)
self.add_event("ocp:like_song", self.handle_like_intent, is_intent=True)
self.add_event("ocp:legacy_cps", self.handle_legacy_cps, is_intent=True)

@property
def available_SEI(self):
Expand Down Expand Up @@ -1015,30 +1015,7 @@ def handle_legacy_cps(self, message: Message):

def shutdown(self):
self.mycroft_cps.shutdown()
self.bus.remove("ovos.common_play.search", self.handle_search_query)
self.bus.remove("ovos.common_play.play_search", self.handle_play_search)
self.bus.remove('ovos.common_play.status.response', self.handle_player_state_update)
self.bus.remove('ovos.common_play.track.state', self.handle_track_state_update)
self.bus.remove('ovos.common_play.SEI.get.response', self.handle_get_SEIs)
self.bus.remove('ovos.common_play.register_keyword', self.handle_skill_keyword_register)
self.bus.remove('ovos.common_play.deregister_keyword', self.handle_skill_keyword_deregister)
self.bus.remove('ovos.common_play.announce', self.handle_skill_register)
self.bus.remove("mycroft.audio.playing_track", self._handle_legacy_audio_start)
self.bus.remove("mycroft.audio.queue_end", self._handle_legacy_audio_end)
self.bus.remove("mycroft.audio.service.pause", self._handle_legacy_audio_pause)
self.bus.remove("mycroft.audio.service.resume", self._handle_legacy_audio_resume)
self.bus.remove("mycroft.audio.service.stop", self._handle_legacy_audio_stop)
self.bus.remove("ocp:play", self.handle_play_intent)
self.bus.remove("ocp:play_favorites", self.handle_play_favorites_intent)
self.bus.remove("ocp:open", self.handle_open_intent)
self.bus.remove("ocp:next", self.handle_next_intent)
self.bus.remove("ocp:prev", self.handle_prev_intent)
self.bus.remove("ocp:pause", self.handle_pause_intent)
self.bus.remove("ocp:resume", self.handle_resume_intent)
self.bus.remove("ocp:media_stop", self.handle_stop_intent)
self.bus.remove("ocp:search_error", self.handle_search_error_intent)
self.bus.remove("ocp:like_song", self.handle_like_intent)
self.bus.remove("ocp:legacy_cps", self.handle_legacy_cps)
self.default_shutdown() # remove events registered via self.add_event


class LegacyCommonPlay:
Expand Down
22 changes: 16 additions & 6 deletions ovos_core/intent_services/stop_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,11 @@ def match_stop_high(self, utterances, lang, message):
# 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 ovos_core.intent_services.IntentMatch(intent_service='Stop',
intent_type="ovos.utterance.handled",
intent_data={"conf": conf},
skill_id=skill_id,
utterance=utterance)
return None

def match_stop_medium(self, utterances, lang, message):
Expand Down Expand Up @@ -210,13 +213,20 @@ def match_stop_low(self, utterances, lang, message):
# 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 ovos_core.intent_services.IntentMatch(intent_service='Stop',
intent_type="ovos.utterance.handled",
# emit instead of intent message
intent_data={"conf": conf},
skill_id=skill_id, utterance=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)
return ovos_core.intent_services.IntentMatch(intent_service='Stop',
intent_type="ovos.utterance.handled",
# emit instead of intent message {"conf": conf},
intent_data={},
skill_id=None,
utterance=utterance)

def voc_match(self, utt: str, voc_filename: str, lang: str,
exact: bool = False):
Expand Down
2 changes: 1 addition & 1 deletion requirements/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ ovos-plugin-manager<0.1.0, >=0.0.25
ovos-config~=0.0,>=0.0.13a8
ovos-lingua-franca>=0.4.7
ovos-backend-client~=0.1.0
ovos-workshop<0.1.0, >=0.0.16a27
ovos-workshop<0.1.0, >=0.0.16a30
# provides plugins and classic machine learning framework
ovos-classifiers<0.1.0, >=0.0.0a53

Expand Down
1 change: 1 addition & 0 deletions test/end2end/routing/test_sched.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ def wait_for_n_messages(n):
"mycroft.scheduler.schedule_event",

"mycroft.skill.handler.complete", # intent code end
"ovos.utterance.handled", # handle_utterance returned (intent service)
"ovos.session.update_default", # session update (end of utterance default sync)

# skill event triggering after 3 seconds
Expand Down
Loading

0 comments on commit 3cf75ed

Please sign in to comment.