From a9f6ad47f7702d2eab1a92852ef1bb9e694df687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Trellu?= Date: Sun, 8 Mar 2026 21:37:33 -0400 Subject: [PATCH 1/5] Split date-time runtime fixes from locale PR --- __init__.py | 117 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 96 insertions(+), 21 deletions(-) diff --git a/__init__.py b/__init__.py index 59b5c63f..af965d42 100644 --- a/__init__.py +++ b/__init__.py @@ -56,6 +56,34 @@ def speakable_timezone(tz): class TimeSkill(OVOSSkill): """A skill for interacting with date and time information.""" + AMBIGUOUS_LOCATIONS = { + "australie", + "bresil", + "brésil", + "canada", + "espagne", + "indonesie", + "indonésie", + "mexique", + "nouvelle-zelande", + "nouvelle-zélande", + "russie", + "usa", + "etats-unis", + "états-unis", + "les usa", + "les etats-unis", + "les états-unis", + "les etats-unis d'amerique", + "les états-unis d'amérique" + } + NON_LOCATION_PHRASES = { + "ce moment", + "ce moment-ci", + "ce moment la", + "ce moment-là" + } + @classproperty def runtime_requirements(self): """this skill does not need internet""" @@ -155,14 +183,29 @@ def _extract_location(self, utt: str) -> str: pat = pat.strip() if pat and pat[0] == "#": continue - res = re.search(pat, utt) + res = re.search(pat, utt, flags=re.IGNORECASE) if res: try: - return res.group("Location") + return res.group("Location").strip(" \t,;:.!?") except IndexError: pass return None + @classmethod + def _is_ambiguous_location(cls, location_string: str) -> bool: + """Return True when a location name spans multiple timezones.""" + return location_string.strip().lower() in cls.AMBIGUOUS_LOCATIONS + + @classmethod + def _sanitize_location(cls, location_string: Optional[str]) -> Optional[str]: + """Discard French adverbial phrases accidentally captured as locations.""" + if not location_string: + return None + cleaned = location_string.strip(" \t,;:.!?") + if cleaned.lower() in cls.NON_LOCATION_PHRASES: + return None + return cleaned + @staticmethod def _get_timezone_from_builtins(location_string: str) -> Optional[datetime.tzinfo]: """Attempt to resolve a timezone from a location name using geocoding. @@ -262,6 +305,8 @@ def get_timezone_in_location(self, location_string: str) -> datetime.tzinfo: Returns: datetime.tzinfo: The timezone object if resolved, else None. """ + if self._is_ambiguous_location(location_string): + return None timezone = self._get_timezone_from_builtins(location_string) if not timezone: timezone = self._get_timezone_from_table(location_string) @@ -282,6 +327,7 @@ def get_datetime(self, location: str = None, Returns: Optional[datetime.datetime]: The localized datetime, or None if timezone cannot be resolved. """ + location = self._sanitize_location(location) if location: tz = self.get_timezone_in_location(location) if not tz: @@ -296,7 +342,7 @@ def get_datetime(self, location: str = None, return dt def get_spoken_time(self, location: str = None, force_ampm=False, - anchor_date: datetime.datetime = None) -> str: + anchor_date: datetime.datetime = None) -> Optional[str]: """Get a human-readable spoken version of the current time. Args: @@ -308,6 +354,8 @@ def get_spoken_time(self, location: str = None, force_ampm=False, str: A spoken-friendly representation of the time. """ dt = self.get_datetime(location, anchor_date) + if not dt: + return None # speak AM/PM when talking about somewhere else say_am_pm = bool(location) or force_ampm @@ -320,7 +368,7 @@ def get_spoken_time(self, location: str = None, force_ampm=False, return s def get_display_time(self, location: str = None, force_ampm=False, - anchor_date: datetime.datetime = None) -> str: + anchor_date: datetime.datetime = None) -> Optional[str]: """Get a display-friendly version of the current time. Args: @@ -332,6 +380,8 @@ def get_display_time(self, location: str = None, force_ampm=False, str: A string representing the display time. """ dt = self.get_datetime(location, anchor_date) + if not dt: + return None # speak AM/PM when talking about somewhere else say_am_pm = bool(location) or force_ampm return nice_time(dt, lang=self.lang, @@ -363,18 +413,20 @@ def get_display_date(self, location: str = None, ###################################################################### # Time queries / display - def speak_time(self, dialog: str, location: str = None): + def speak_time(self, dialog: str, location: str = None, + anchor_date: datetime.datetime = None): """Speak the current time. Optionally at a location speaks an error if timezone for requested location could not be detected""" + location = self._sanitize_location(location) if location: - current_time = self.get_spoken_time(location) + current_time = self.get_spoken_time(location, anchor_date=anchor_date) if not current_time: self.speak_dialog("time.tz.not.found", {"location": location}) return - time_string = self.get_display_time(location) + time_string = self.get_display_time(location, anchor_date=anchor_date) else: - current_time = self.get_spoken_time() - time_string = self.get_display_time() + current_time = self.get_spoken_time(anchor_date=anchor_date) + time_string = self.get_display_time(anchor_date=anchor_date) # speak it self.speak_dialog(dialog, {"time": current_time}) @@ -386,7 +438,9 @@ def speak_time(self, dialog: str, location: str = None): def handle_query_time(self, message): """Handle queries about the current time.""" utt = message.data.get('utterance', "") - location = message.data.get("location") or self._extract_location(utt) + location = self._sanitize_location( + message.data.get("location") or self._extract_location(utt) + ) # speak it self.speak_time("time.current", location=location) @@ -400,10 +454,12 @@ def handle_query_future_time(self, message): self.handle_query_time(message) return - location = message.data.get("location") or self._extract_location(utt) + location = self._sanitize_location( + message.data.get("location") or self._extract_location(utt) + ) # speak it - self.speak_time("time.future", location=location) + self.speak_time("time.future", location=location, anchor_date=dt) ###################################################################### # Date queries @@ -418,7 +474,9 @@ def handle_query_date(self, message, response_type="simple"): dt = now # handle questions ~ "what is the day in sydney" - location_string = message.data.get("location") or self._extract_location(utt) + location_string = self._sanitize_location( + message.data.get("location") or self._extract_location(utt) + ) if location_string: dt = self.get_datetime(location_string, anchor_date=dt) @@ -470,7 +528,14 @@ def handle_current_day(self, message): Args: message: The message object triggering the intent. """ - now = self.get_datetime() # session aware + utt = message.data.get("utterance", "") + location = self._sanitize_location( + message.data.get("location") or self._extract_location(utt) + ) + now = self.get_datetime(location) + if location and not now: + self.speak_dialog("time.tz.not.found", {"location": location}) + return self.speak_dialog("day.current", {"day": nice_day(now, lang=self.lang)}) @@ -530,19 +595,29 @@ def handle_current_year(self, message): @intent_handler("date.future.weekend.intent") def handle_date_future_weekend(self, message): - # Strip year off nice_date as request is inherently close - # Don't pass `now` to `nice_date` as a - # request on Friday will return "tomorrow" """ Handles queries about the upcoming weekend's dates. Determines the dates for the next Saturday and Sunday, formats them for speech, and responds with a dialog containing both dates. """ now = self.get_datetime() - dt = extract_datetime('this saturday', anchorDate=now, lang='en-us')[0] - saturday_date = ', '.join(nice_date(dt, lang=self.lang).split(', ')[:2]) - dt = extract_datetime('this sunday', anchorDate=now, lang='en-us')[0] - sunday_date = ', '.join(nice_date(dt, lang=self.lang).split(', ')[:2]) + utt = message.data.get("utterance", "").lower() + weekday = now.weekday() + + if weekday <= 5: + saturday_dt = now + datetime.timedelta(days=5 - weekday) + else: + saturday_dt = now - datetime.timedelta(days=1) + + current_weekend = any( + phrase in utt for phrase in ("ce week-end", "ce weekend", "ce week end") + ) + if not current_weekend and weekday >= 5: + saturday_dt += datetime.timedelta(days=7) + + sunday_dt = saturday_dt + datetime.timedelta(days=1) + saturday_date = ', '.join(nice_date(saturday_dt, lang=self.lang).split(', ')[:2]) + sunday_date = ', '.join(nice_date(sunday_dt, lang=self.lang).split(', ')[:2]) self.speak_dialog('date.future.weekend', { 'saturday_date': saturday_date, 'sunday_date': sunday_date From 0beaea0d40aa1fdbb01cc1ae3b7407e3a6a49287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Trellu?= Date: Sun, 8 Mar 2026 21:48:59 -0400 Subject: [PATCH 2/5] Move datetime locale exceptions into resources --- __init__.py | 77 +++++++++++------------ locale/en-us/ambiguous_locations.list | 15 +++++ locale/en-us/current_weekend_phrases.list | 1 + locale/en-us/non_location_phrases.list | 5 ++ locale/fr-fr/ambiguous_locations.list | 19 ++++++ locale/fr-fr/current_weekend_phrases.list | 3 + locale/fr-fr/non_location_phrases.list | 4 ++ locale/tr-tr/current_weekend_phrases.list | 1 + 8 files changed, 85 insertions(+), 40 deletions(-) create mode 100644 locale/en-us/ambiguous_locations.list create mode 100644 locale/en-us/current_weekend_phrases.list create mode 100644 locale/en-us/non_location_phrases.list create mode 100644 locale/fr-fr/ambiguous_locations.list create mode 100644 locale/fr-fr/current_weekend_phrases.list create mode 100644 locale/fr-fr/non_location_phrases.list create mode 100644 locale/tr-tr/current_weekend_phrases.list diff --git a/__init__.py b/__init__.py index af965d42..10ac6a14 100644 --- a/__init__.py +++ b/__init__.py @@ -56,34 +56,6 @@ def speakable_timezone(tz): class TimeSkill(OVOSSkill): """A skill for interacting with date and time information.""" - AMBIGUOUS_LOCATIONS = { - "australie", - "bresil", - "brésil", - "canada", - "espagne", - "indonesie", - "indonésie", - "mexique", - "nouvelle-zelande", - "nouvelle-zélande", - "russie", - "usa", - "etats-unis", - "états-unis", - "les usa", - "les etats-unis", - "les états-unis", - "les etats-unis d'amerique", - "les états-unis d'amérique" - } - NON_LOCATION_PHRASES = { - "ce moment", - "ce moment-ci", - "ce moment la", - "ce moment-là" - } - @classproperty def runtime_requirements(self): """this skill does not need internet""" @@ -165,6 +137,22 @@ def use_24hour(self): """ return self.time_format == 'full' + @staticmethod + def _normalize_phrase(text: str) -> str: + """Normalize phrases for case-insensitive locale resource matching.""" + return text.strip(" \t,;:.!?").casefold() + + def _load_locale_phrase_set(self, name: str): + """Load a locale phrase list once and return a normalized set.""" + cache_key = f"{name}.list.normalized" + if cache_key not in self.resources.static: + self.resources.static[cache_key] = { + self._normalize_phrase(phrase) + for phrase in self.resources.load_list_file(name) + if phrase.strip() + } + return self.resources.static[cache_key] + ###################################################################### # parsing def _extract_location(self, utt: str) -> str: @@ -191,21 +179,33 @@ def _extract_location(self, utt: str) -> str: pass return None - @classmethod - def _is_ambiguous_location(cls, location_string: str) -> bool: - """Return True when a location name spans multiple timezones.""" - return location_string.strip().lower() in cls.AMBIGUOUS_LOCATIONS + def _is_ambiguous_location(self, location_string: str) -> bool: + """Return True when a locale marks a location name as timezone-ambiguous.""" + return ( + self._normalize_phrase(location_string) + in self._load_locale_phrase_set("ambiguous_locations") + ) - @classmethod - def _sanitize_location(cls, location_string: Optional[str]) -> Optional[str]: - """Discard French adverbial phrases accidentally captured as locations.""" + def _sanitize_location(self, location_string: Optional[str]) -> Optional[str]: + """Discard locale-specific phrases accidentally captured as locations.""" if not location_string: return None cleaned = location_string.strip(" \t,;:.!?") - if cleaned.lower() in cls.NON_LOCATION_PHRASES: + if ( + self._normalize_phrase(cleaned) + in self._load_locale_phrase_set("non_location_phrases") + ): return None return cleaned + def _mentions_current_weekend(self, utterance: str) -> bool: + """Check whether the active locale explicitly asked for the current weekend.""" + current_weekend_phrases = self._load_locale_phrase_set("current_weekend_phrases") + if not current_weekend_phrases: + return False + normalized_utterance = utterance.casefold() + return any(phrase in normalized_utterance for phrase in current_weekend_phrases) + @staticmethod def _get_timezone_from_builtins(location_string: str) -> Optional[datetime.tzinfo]: """Attempt to resolve a timezone from a location name using geocoding. @@ -609,10 +609,7 @@ def handle_date_future_weekend(self, message): else: saturday_dt = now - datetime.timedelta(days=1) - current_weekend = any( - phrase in utt for phrase in ("ce week-end", "ce weekend", "ce week end") - ) - if not current_weekend and weekday >= 5: + if weekday >= 5 and not self._mentions_current_weekend(utt): saturday_dt += datetime.timedelta(days=7) sunday_dt = saturday_dt + datetime.timedelta(days=1) diff --git a/locale/en-us/ambiguous_locations.list b/locale/en-us/ambiguous_locations.list new file mode 100644 index 00000000..6925ab86 --- /dev/null +++ b/locale/en-us/ambiguous_locations.list @@ -0,0 +1,15 @@ +australia +brazil +canada +indonesia +mexico +new zealand +russia +spain +us +usa +the us +the usa +united states +the united states +the united states of america diff --git a/locale/en-us/current_weekend_phrases.list b/locale/en-us/current_weekend_phrases.list new file mode 100644 index 00000000..a4f31eab --- /dev/null +++ b/locale/en-us/current_weekend_phrases.list @@ -0,0 +1 @@ +this weekend diff --git a/locale/en-us/non_location_phrases.list b/locale/en-us/non_location_phrases.list new file mode 100644 index 00000000..d5e2e147 --- /dev/null +++ b/locale/en-us/non_location_phrases.list @@ -0,0 +1,5 @@ +now +right now +the moment +this moment +the current moment diff --git a/locale/fr-fr/ambiguous_locations.list b/locale/fr-fr/ambiguous_locations.list new file mode 100644 index 00000000..669b5c33 --- /dev/null +++ b/locale/fr-fr/ambiguous_locations.list @@ -0,0 +1,19 @@ +australie +bresil +brésil +canada +espagne +indonesie +indonésie +mexique +nouvelle-zelande +nouvelle-zélande +russie +usa +etats-unis +états-unis +les usa +les etats-unis +les états-unis +les etats-unis d'amerique +les états-unis d'amérique diff --git a/locale/fr-fr/current_weekend_phrases.list b/locale/fr-fr/current_weekend_phrases.list new file mode 100644 index 00000000..5ec6c24e --- /dev/null +++ b/locale/fr-fr/current_weekend_phrases.list @@ -0,0 +1,3 @@ +ce week-end +ce weekend +ce week end diff --git a/locale/fr-fr/non_location_phrases.list b/locale/fr-fr/non_location_phrases.list new file mode 100644 index 00000000..fc81c217 --- /dev/null +++ b/locale/fr-fr/non_location_phrases.list @@ -0,0 +1,4 @@ +ce moment +ce moment-ci +ce moment la +ce moment-là diff --git a/locale/tr-tr/current_weekend_phrases.list b/locale/tr-tr/current_weekend_phrases.list new file mode 100644 index 00000000..2ad8f1a8 --- /dev/null +++ b/locale/tr-tr/current_weekend_phrases.list @@ -0,0 +1 @@ +bu hafta sonu From 0274e98a477ed3c905db235f9e96526e30ecaef7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Trellu?= Date: Sun, 8 Mar 2026 21:51:23 -0400 Subject: [PATCH 3/5] Split datetime locale resources into separate PR --- locale/en-us/ambiguous_locations.list | 15 --------------- locale/en-us/current_weekend_phrases.list | 1 - locale/en-us/non_location_phrases.list | 5 ----- locale/fr-fr/ambiguous_locations.list | 19 ------------------- locale/fr-fr/current_weekend_phrases.list | 3 --- locale/fr-fr/non_location_phrases.list | 4 ---- locale/tr-tr/current_weekend_phrases.list | 1 - 7 files changed, 48 deletions(-) delete mode 100644 locale/en-us/ambiguous_locations.list delete mode 100644 locale/en-us/current_weekend_phrases.list delete mode 100644 locale/en-us/non_location_phrases.list delete mode 100644 locale/fr-fr/ambiguous_locations.list delete mode 100644 locale/fr-fr/current_weekend_phrases.list delete mode 100644 locale/fr-fr/non_location_phrases.list delete mode 100644 locale/tr-tr/current_weekend_phrases.list diff --git a/locale/en-us/ambiguous_locations.list b/locale/en-us/ambiguous_locations.list deleted file mode 100644 index 6925ab86..00000000 --- a/locale/en-us/ambiguous_locations.list +++ /dev/null @@ -1,15 +0,0 @@ -australia -brazil -canada -indonesia -mexico -new zealand -russia -spain -us -usa -the us -the usa -united states -the united states -the united states of america diff --git a/locale/en-us/current_weekend_phrases.list b/locale/en-us/current_weekend_phrases.list deleted file mode 100644 index a4f31eab..00000000 --- a/locale/en-us/current_weekend_phrases.list +++ /dev/null @@ -1 +0,0 @@ -this weekend diff --git a/locale/en-us/non_location_phrases.list b/locale/en-us/non_location_phrases.list deleted file mode 100644 index d5e2e147..00000000 --- a/locale/en-us/non_location_phrases.list +++ /dev/null @@ -1,5 +0,0 @@ -now -right now -the moment -this moment -the current moment diff --git a/locale/fr-fr/ambiguous_locations.list b/locale/fr-fr/ambiguous_locations.list deleted file mode 100644 index 669b5c33..00000000 --- a/locale/fr-fr/ambiguous_locations.list +++ /dev/null @@ -1,19 +0,0 @@ -australie -bresil -brésil -canada -espagne -indonesie -indonésie -mexique -nouvelle-zelande -nouvelle-zélande -russie -usa -etats-unis -états-unis -les usa -les etats-unis -les états-unis -les etats-unis d'amerique -les états-unis d'amérique diff --git a/locale/fr-fr/current_weekend_phrases.list b/locale/fr-fr/current_weekend_phrases.list deleted file mode 100644 index 5ec6c24e..00000000 --- a/locale/fr-fr/current_weekend_phrases.list +++ /dev/null @@ -1,3 +0,0 @@ -ce week-end -ce weekend -ce week end diff --git a/locale/fr-fr/non_location_phrases.list b/locale/fr-fr/non_location_phrases.list deleted file mode 100644 index fc81c217..00000000 --- a/locale/fr-fr/non_location_phrases.list +++ /dev/null @@ -1,4 +0,0 @@ -ce moment -ce moment-ci -ce moment la -ce moment-là diff --git a/locale/tr-tr/current_weekend_phrases.list b/locale/tr-tr/current_weekend_phrases.list deleted file mode 100644 index 2ad8f1a8..00000000 --- a/locale/tr-tr/current_weekend_phrases.list +++ /dev/null @@ -1 +0,0 @@ -bu hafta sonu From cd5061afb97e40ac993aca391c6fb501cc5a9a5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Trellu?= Date: Tue, 7 Apr 2026 19:07:58 -0400 Subject: [PATCH 4/5] Tidy datetime runtime location handling --- __init__.py | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/__init__.py b/__init__.py index 10ac6a14..3b79933e 100644 --- a/__init__.py +++ b/__init__.py @@ -198,6 +198,16 @@ def _sanitize_location(self, location_string: Optional[str]) -> Optional[str]: return None return cleaned + def _resolve_location(self, + location_string: Optional[str] = None, + utterance: str = "") -> Optional[str]: + """Resolve a sanitized location from an explicit slot or from the utterance.""" + if location_string: + return self._sanitize_location(location_string) + if utterance: + return self._sanitize_location(self._extract_location(utterance)) + return None + def _mentions_current_weekend(self, utterance: str) -> bool: """Check whether the active locale explicitly asked for the current weekend.""" current_weekend_phrases = self._load_locale_phrase_set("current_weekend_phrases") @@ -327,7 +337,6 @@ def get_datetime(self, location: str = None, Returns: Optional[datetime.datetime]: The localized datetime, or None if timezone cannot be resolved. """ - location = self._sanitize_location(location) if location: tz = self.get_timezone_in_location(location) if not tz: @@ -417,7 +426,6 @@ def speak_time(self, dialog: str, location: str = None, anchor_date: datetime.datetime = None): """Speak the current time. Optionally at a location speaks an error if timezone for requested location could not be detected""" - location = self._sanitize_location(location) if location: current_time = self.get_spoken_time(location, anchor_date=anchor_date) if not current_time: @@ -432,15 +440,14 @@ def speak_time(self, dialog: str, location: str = None, self.speak_dialog(dialog, {"time": current_time}) # and briefly show the time - self.show_time(time_string) + if time_string: + self.show_time(time_string) @intent_handler("what.time.is.it.intent") def handle_query_time(self, message): """Handle queries about the current time.""" utt = message.data.get('utterance', "") - location = self._sanitize_location( - message.data.get("location") or self._extract_location(utt) - ) + location = self._resolve_location(message.data.get("location"), utt) # speak it self.speak_time("time.current", location=location) @@ -454,9 +461,7 @@ def handle_query_future_time(self, message): self.handle_query_time(message) return - location = self._sanitize_location( - message.data.get("location") or self._extract_location(utt) - ) + location = self._resolve_location(message.data.get("location"), utt) # speak it self.speak_time("time.future", location=location, anchor_date=dt) @@ -474,9 +479,7 @@ def handle_query_date(self, message, response_type="simple"): dt = now # handle questions ~ "what is the day in sydney" - location_string = self._sanitize_location( - message.data.get("location") or self._extract_location(utt) - ) + location_string = self._resolve_location(message.data.get("location"), utt) if location_string: dt = self.get_datetime(location_string, anchor_date=dt) @@ -529,9 +532,7 @@ def handle_current_day(self, message): message: The message object triggering the intent. """ utt = message.data.get("utterance", "") - location = self._sanitize_location( - message.data.get("location") or self._extract_location(utt) - ) + location = self._resolve_location(message.data.get("location"), utt) now = self.get_datetime(location) if location and not now: self.speak_dialog("time.tz.not.found", {"location": location}) @@ -604,6 +605,8 @@ def handle_date_future_weekend(self, message): utt = message.data.get("utterance", "").lower() weekday = now.weekday() + # On Saturday/Sunday, default to the upcoming weekend unless the user + # explicitly asked for the current weekend in this locale. if weekday <= 5: saturday_dt = now + datetime.timedelta(days=5 - weekday) else: From 2076bb970840adb303f296dfb1b9ad6f6195df4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Trellu?= Date: Tue, 7 Apr 2026 19:14:43 -0400 Subject: [PATCH 5/5] Fix loader test imports --- test/unittests/test_skill_loading.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unittests/test_skill_loading.py b/test/unittests/test_skill_loading.py index 01f638ca..365d2de4 100644 --- a/test/unittests/test_skill_loading.py +++ b/test/unittests/test_skill_loading.py @@ -1,10 +1,10 @@ import unittest from os.path import dirname -from mycroft.skills.skill_loader import PluginSkillLoader, SkillLoader from ovos_plugin_manager.skills import find_skill_plugins from ovos_utils.messagebus import FakeBus -from skill_ovos_date_time import TimeSkill +from ovos_workshop.skill_launcher import PluginSkillLoader, SkillLoader +from ovos_skill_date_time import TimeSkill class TestSkillLoading(unittest.TestCase):