Better event hooks filters.

- Support for nested attributes on event hook conditions. Things like
  these are now possible:

```
from platypush.event.hook import hook
from platypush.message.event.entities import EntityUpdateEvent

@hook(EntityUpdateEvent, entity={"external_id": "system:cpu"})
def on_cpu_update_event(event: EntityUpdateEvent, **_):
    print(event.args["entity"]["percent"])
```

- The scoring/regex extraction/partial string match logic in
  `_matches_argument` is actually only needed for
  `SpeechRecognizedEvent`. Other events don't need these features, and
  event hooks may be actually triggered unexpectedly in case of partial
  matches. Therefore, the "complex" `_matches_argument` has been moved
  as an override only for `SpeechRecognizedEvent`, and all the other
  events will perform simple key-value matching.
This commit is contained in:
Fabio Manganiello 2023-04-26 01:45:58 +02:00
parent ee54e0edbf
commit 245472a4c5
Signed by: blacklight
GPG key ID: D90FBA7F76362774
3 changed files with 217 additions and 128 deletions

View file

@ -2,8 +2,6 @@ import copy
import json import json
import logging import logging
import random import random
import re
import sys
import time import time
from datetime import date from datetime import date
@ -31,7 +29,7 @@ class Event(Message):
timestamp=None, timestamp=None,
logging_level=logging.INFO, logging_level=logging.INFO,
disable_web_clients_notification=False, disable_web_clients_notification=False,
**kwargs **kwargs,
): ):
""" """
:param target: Target node :param target: Target node
@ -53,7 +51,7 @@ class Event(Message):
self.id = id if id else self._generate_id() self.id = id if id else self._generate_id()
self.target = target if target else Config.get('device_id') self.target = target if target else Config.get('device_id')
self.origin = origin if origin else Config.get('device_id') self.origin = origin if origin else Config.get('device_id')
self.type = '{}.{}'.format(self.__class__.__module__, self.__class__.__name__) self.type = f'{self.__class__.__module__}.{self.__class__.__name__}'
self.args = kwargs self.args = kwargs
self.disable_web_clients_notification = disable_web_clients_notification self.disable_web_clients_notification = disable_web_clients_notification
@ -71,8 +69,10 @@ class Event(Message):
@classmethod @classmethod
def build(cls, msg): def build(cls, msg):
"""Builds an event message from a JSON UTF-8 string/bytearray, a """
dictionary, or another Event""" Builds an event message from a JSON UTF-8 string/bytearray, a
dictionary, or another Event
"""
msg = super().parse(msg) msg = super().parse(msg)
event_type = msg['args'].pop('type') event_type = msg['args'].pop('type')
@ -88,7 +88,42 @@ class Event(Message):
@staticmethod @staticmethod
def _generate_id(): def _generate_id():
"""Generate a unique event ID""" """Generate a unique event ID"""
return ''.join(['{:02x}'.format(random.randint(0, 255)) for _ in range(16)]) return ''.join([f'{random.randint(0, 255):02x}' for _ in range(16)])
def _matches_condition(
self,
condition: dict,
args: dict,
result: "EventMatchResult",
match_scores: list,
) -> bool:
for attr, value in condition.items():
if attr not in args:
return False
if isinstance(args[attr], str):
self._matches_argument(
argname=attr, condition_value=value, args=args, result=result
)
if result.is_match:
match_scores.append(result.score)
else:
return False
elif isinstance(value, dict):
if not isinstance(args[attr], dict):
return False
return self._matches_condition(
condition=value,
args=args[attr],
result=result,
match_scores=match_scores,
)
elif args[attr] != value:
return False
return True
def matches_condition(self, condition): def matches_condition(self, condition):
""" """
@ -102,21 +137,12 @@ class Event(Message):
if not isinstance(self, condition.type): if not isinstance(self, condition.type):
return result return result
for attr, value in condition.args.items(): if not self._matches_condition(
if attr not in self.args: condition=condition.args,
return result args=self.args,
result=result,
if isinstance(self.args[attr], str): match_scores=match_scores,
arg_result = self._matches_argument(argname=attr, condition_value=value) ):
if arg_result.is_match:
match_scores.append(arg_result.score)
for parsed_arg, parsed_value in arg_result.parsed_args.items():
result.parsed_args[parsed_arg] = parsed_value
else:
return result
elif self.args[attr] != value:
# TODO proper support for list and dictionary matches
return result return result
result.is_match = True result.is_match = True
@ -125,75 +151,20 @@ class Event(Message):
return result return result
def _matches_argument(self, argname, condition_value): def _matches_argument(
self, argname, condition_value, args, result: "EventMatchResult"
):
""" """
Returns an EventMatchResult if the event argument [argname] matches Returns an EventMatchResult if the event argument [argname] matches
[condition_value]. [condition_value].
- Example:
- self.args = {
'phrase': 'Hey dude turn on the living room lights'
}
- self._matches_argument(argname='phrase', condition_value='Turn on the ${lights} lights')
will return EventMatchResult(is_match=True, parsed_args={ 'lights': 'living room' })
- self._matches_argument(argname='phrase', condition_value='Turn off the ${lights} lights')
will return EventMatchResult(is_match=False, parsed_args={})
""" """
result = EventMatchResult(is_match=False) # Simple equality match by default. It can be overridden by the derived classes.
if self.args.get(argname) == condition_value: result.is_match = args.get(argname) == condition_value
# In case of an exact match, return immediately if result.is_match:
result.is_match = True result.score += 2
result.score = sys.maxsize
return result
event_tokens = re.split(r'\s+', self.args.get(argname, '').strip().lower())
condition_tokens = re.split(r'\s+', condition_value.strip().lower())
while event_tokens and condition_tokens:
event_token = event_tokens[0]
condition_token = condition_tokens[0]
if event_token == condition_token:
event_tokens.pop(0)
condition_tokens.pop(0)
result.score += 1.5
elif re.search(condition_token, event_token):
m = re.search('({})'.format(condition_token), event_token)
if m and m.group(1):
event_tokens.pop(0)
result.score += 1.25
condition_tokens.pop(0)
else: else:
m = re.match(r'[^\\]*\${(.+?)}', condition_token) result.score = 0
if m:
argname = m.group(1)
if argname not in result.parsed_args:
result.parsed_args[argname] = event_token
result.score += 1.0
else:
result.parsed_args[argname] += ' ' + event_token
if (len(condition_tokens) == 1 and len(event_tokens) == 1) or (
len(event_tokens) > 1
and len(condition_tokens) > 1
and event_tokens[1] == condition_tokens[1]
):
# Stop appending tokens to this argument, as the next
# condition will be satisfied as well
condition_tokens.pop(0)
event_tokens.pop(0)
else:
result.score -= 1.0
event_tokens.pop(0)
# It's a match if all the tokens in the condition string have been satisfied
result.is_match = len(condition_tokens) == 0
return result
def __str__(self): def __str__(self):
""" """
@ -218,11 +189,13 @@ class Event(Message):
class EventMatchResult: class EventMatchResult:
"""When comparing an event against an event condition, you want to """
When comparing an event against an event condition, you want to
return this object. It contains the match status (True or False), return this object. It contains the match status (True or False),
any parsed arguments, and a match_score that identifies how "strong" any parsed arguments, and a match_score that identifies how "strong"
the match is - in case of multiple event matches, the ones with the the match is - in case of multiple event matches, the ones with the
highest score will win""" highest score will win.
"""
def __init__(self, is_match, score=0.0, parsed_args=None): def __init__(self, is_match, score=0.0, parsed_args=None):
self.is_match = is_match self.is_match = is_match
@ -231,6 +204,9 @@ class EventMatchResult:
def flatten(args): def flatten(args):
"""
Flatten a nested dictionary for string serialization.
"""
if isinstance(args, dict): if isinstance(args, dict):
for key, value in args.items(): for key, value in args.items():
if isinstance(value, date): if isinstance(value, date):

View file

@ -1,13 +1,16 @@
import logging import logging
import re
import sys
from typing_extensions import override
from platypush.context import get_backend, get_plugin from platypush.context import get_backend, get_plugin
from platypush.message.event import Event from platypush.message.event import Event
class AssistantEvent(Event): class AssistantEvent(Event):
""" Base class for assistant events """ """Base class for assistant events"""
def __init__(self, assistant=None, *args, **kwargs): def __init__(self, *args, assistant=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.logger = logging.getLogger('platypush:assistant') self.logger = logging.getLogger('platypush:assistant')
@ -20,7 +23,9 @@ class AssistantEvent(Event):
self._assistant = get_plugin('assistant.google.pushtotalk') self._assistant = get_plugin('assistant.google.pushtotalk')
if not self._assistant: if not self._assistant:
self.logger.warning('Assistant plugin/backend not configured/initialized') self.logger.warning(
'Assistant plugin/backend not configured/initialized'
)
self._assistant = None self._assistant = None
@ -38,7 +43,7 @@ class ConversationEndEvent(AssistantEvent):
Event triggered when a conversation ends Event triggered when a conversation ends
""" """
def __init__(self, with_follow_on_turn=False, *args, **kwargs): def __init__(self, *args, with_follow_on_turn=False, **kwargs):
""" """
:param with_follow_on_turn: Set to true if the conversation expects a user follow-up, false otherwise :param with_follow_on_turn: Set to true if the conversation expects a user follow-up, false otherwise
:type with_follow_on_turn: str :type with_follow_on_turn: str
@ -75,9 +80,6 @@ class NoResponseEvent(ConversationEndEvent):
Event triggered when a conversation ends with no response Event triggered when a conversation ends with no response
""" """
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class SpeechRecognizedEvent(AssistantEvent): class SpeechRecognizedEvent(AssistantEvent):
""" """
@ -105,18 +107,89 @@ class SpeechRecognizedEvent(AssistantEvent):
return result return result
@override
def _matches_argument(self, argname, condition_value, args, result):
"""
Overrides the default `_matches_argument` method to allow partial
phrase matches and text extraction.
Example::
args = {
'phrase': 'Hey dude turn on the living room lights'
}
- `self._matches_argument(argname='phrase', condition_value='Turn on the ${lights} lights')`
will return `EventMatchResult(is_match=True, parsed_args={ 'lights': 'living room' })`
- `self._matches_argument(argname='phrase', condition_value='Turn off the ${lights} lights')`
will return `EventMatchResult(is_match=False, parsed_args={})`
"""
if args.get(argname) == condition_value:
# In case of an exact match, return immediately
result.is_match = True
result.score = sys.maxsize
return result
event_tokens = re.split(r'\s+', args.get(argname, '').strip().lower())
condition_tokens = re.split(r'\s+', condition_value.strip().lower())
while event_tokens and condition_tokens:
event_token = event_tokens[0]
condition_token = condition_tokens[0]
if event_token == condition_token:
event_tokens.pop(0)
condition_tokens.pop(0)
result.score += 1.5
elif re.search(condition_token, event_token):
m = re.search(f'({condition_token})', event_token)
if m and m.group(1):
event_tokens.pop(0)
result.score += 1.25
condition_tokens.pop(0)
else:
m = re.match(r'[^\\]*\${(.+?)}', condition_token)
if m:
argname = m.group(1)
if argname not in result.parsed_args:
result.parsed_args[argname] = event_token
result.score += 1.0
else:
result.parsed_args[argname] += ' ' + event_token
if (len(condition_tokens) == 1 and len(event_tokens) == 1) or (
len(event_tokens) > 1
and len(condition_tokens) > 1
and event_tokens[1] == condition_tokens[1]
):
# Stop appending tokens to this argument, as the next
# condition will be satisfied as well
condition_tokens.pop(0)
event_tokens.pop(0)
else:
result.score -= 1.0
event_tokens.pop(0)
# It's a match if all the tokens in the condition string have been satisfied
result.is_match = len(condition_tokens) == 0
return result
class HotwordDetectedEvent(AssistantEvent): class HotwordDetectedEvent(AssistantEvent):
""" """
Event triggered when a custom hotword is detected Event triggered when a custom hotword is detected
""" """
def __init__(self, hotword=None, *args, **kwargs): def __init__(self, *args, hotword=None, **kwargs):
""" """
:param hotword: The detected user hotword :param hotword: The detected user hotword
:type hotword: str :type hotword: str
""" """
super().__init__(*args, hotword=hotword, **kwargs) super().__init__(*args, hotword=hotword, **kwargs)
@ -134,67 +207,47 @@ class AlertStartedEvent(AssistantEvent):
Event triggered when an alert starts on the assistant Event triggered when an alert starts on the assistant
""" """
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class AlertEndEvent(AssistantEvent): class AlertEndEvent(AssistantEvent):
""" """
Event triggered when an alert ends on the assistant Event triggered when an alert ends on the assistant
""" """
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class AlarmStartedEvent(AlertStartedEvent): class AlarmStartedEvent(AlertStartedEvent):
""" """
Event triggered when an alarm starts on the assistant Event triggered when an alarm starts on the assistant
""" """
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class AlarmEndEvent(AlertEndEvent): class AlarmEndEvent(AlertEndEvent):
""" """
Event triggered when an alarm ends on the assistant Event triggered when an alarm ends on the assistant
""" """
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class TimerStartedEvent(AlertStartedEvent): class TimerStartedEvent(AlertStartedEvent):
""" """
Event triggered when a timer starts on the assistant Event triggered when a timer starts on the assistant
""" """
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class TimerEndEvent(AlertEndEvent): class TimerEndEvent(AlertEndEvent):
""" """
Event triggered when a timer ends on the assistant Event triggered when a timer ends on the assistant
""" """
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class MicMutedEvent(AssistantEvent): class MicMutedEvent(AssistantEvent):
""" """
Event triggered when the microphone is muted. Event triggered when the microphone is muted.
""" """
pass
class MicUnmutedEvent(AssistantEvent): class MicUnmutedEvent(AssistantEvent):
""" """
Event triggered when the microphone is muted. Event triggered when the microphone is muted.
""" """
pass
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -1,28 +1,88 @@
import pytest import pytest
from platypush.event.hook import EventCondition from platypush.event.hook import EventCondition
from platypush.message.event.assistant import SpeechRecognizedEvent
from platypush.message.event.ping import PingEvent from platypush.message.event.ping import PingEvent
condition = EventCondition.build({
'type': 'platypush.message.event.ping.PingEvent',
'message': 'This is (the)? answer: ${answer}'
})
def test_event_parse(): def test_event_parse():
""" """
Test for the events/conditions matching logic. Test for the events/conditions matching logic.
""" """
message = "GARBAGE GARBAGE this is the answer: 42" condition = EventCondition.build(
event = PingEvent(message=message) {
'type': 'platypush.message.event.ping.PingEvent',
'message': 'This is a test message',
}
)
event = PingEvent(message=condition.args['message'])
result = event.matches_condition(condition)
assert result.is_match
event = PingEvent(message="This is not a test message")
result = event.matches_condition(condition)
assert not result.is_match
def test_nested_event_condition():
"""
Verify that nested event conditions work as expected.
"""
condition = EventCondition.build(
{
'type': 'platypush.message.event.ping.PingEvent',
'message': {
'foo': 'bar',
},
}
)
event = PingEvent(
message={
'foo': 'bar',
'baz': 'clang',
}
)
assert event.matches_condition(condition).is_match
event = PingEvent(
message={
'something': 'else',
}
)
assert not event.matches_condition(condition).is_match
event = PingEvent(
message={
'foo': 'baz',
}
)
assert not event.matches_condition(condition).is_match
def test_speech_recognized_event_parse():
"""
Test the event parsing and text extraction logic for the
SpeechRecognizedEvent.
"""
condition = EventCondition.build(
{
'type': 'platypush.message.event.assistant.SpeechRecognizedEvent',
'phrase': 'This is (the)? answer: ${answer}',
}
)
event = SpeechRecognizedEvent(phrase="GARBAGE GARBAGE this is the answer: 42")
result = event.matches_condition(condition) result = event.matches_condition(condition)
assert result.is_match assert result.is_match
assert 'answer' in result.parsed_args assert 'answer' in result.parsed_args
assert result.parsed_args['answer'] == '42' assert result.parsed_args['answer'] == '42'
message = "what is not the answer? 43" event = PingEvent(phrase="what is not the answer? 43")
event = PingEvent(message=message)
result = event.matches_condition(condition) result = event.matches_condition(condition)
assert not result.is_match assert not result.is_match