Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ jobs:
with:
python_versions: '["3.10", "3.11", "3.12", "3.13", "3.14"]'
install_extras: 'dev'
test_path: 'test'
test_path: 'test/unittests'
4 changes: 2 additions & 2 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@ jobs:
with:
python_version: '3.11'
coverage_source: 'ovos_utterance_plugin_cancel'
test_path: 'test/'
install_extras: ''
test_path: 'test/unittests'
install_extras: '.[dev]'
min_coverage: 0
14 changes: 14 additions & 0 deletions .github/workflows/opm-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name: OPM Plugin Check

on:
pull_request:
branches: [dev, master, main]
workflow_dispatch:

jobs:
opm_check:
uses: OpenVoiceOS/gh-automations/.github/workflows/opm-check.yml@dev
secrets: inherit
with:
python_version: '3.11'
plugin_type: 'auto'
23 changes: 23 additions & 0 deletions .github/workflows/ovoscope.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Ovoscope End-to-End Tests

on:
push:
branches: [dev]
pull_request:
branches: [dev]
workflow_dispatch:

jobs:
ovoscope:
uses: OpenVoiceOS/gh-automations/.github/workflows/ovoscope.yml@dev
secrets: inherit
with:
runner: "ubuntu-latest"
python_version: "3.11"
install_extras: "test"
test_path: "test/end2end/"
# The cancel transformer fires before intent matching, so the
# ovoscope cases here do not depend on any intent engine, and
# transformers don't emit bus events so bus-coverage tracking
# would be empty anyway.
pr_comment: true
30 changes: 9 additions & 21 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,40 +1,28 @@
# Changelog

## [0.2.8a1](https://github.com/OpenVoiceOS/ovos-utterance-plugin-cancel/tree/0.2.8a1) (2026-04-17)
## [0.3.0a2](https://github.com/OpenVoiceOS/ovos-utterance-plugin-cancel/tree/0.3.0a2) (2026-06-16)

[Full Changelog](https://github.com/OpenVoiceOS/ovos-utterance-plugin-cancel/compare/0.2.7a2...0.2.8a1)
[Full Changelog](https://github.com/OpenVoiceOS/ovos-utterance-plugin-cancel/compare/0.3.0a1...0.3.0a2)

**Merged pull requests:**

- fix: drop setup.py and requirements.txt [\#30](https://github.com/OpenVoiceOS/ovos-utterance-plugin-cancel/pull/30) ([JarbasAl](https://github.com/JarbasAl))
- chore: add missing shared CI workflows [\#38](https://github.com/OpenVoiceOS/ovos-utterance-plugin-cancel/pull/38) ([JarbasAl](https://github.com/JarbasAl))

## [0.2.7a2](https://github.com/OpenVoiceOS/ovos-utterance-plugin-cancel/tree/0.2.7a2) (2026-04-17)
## [0.3.0a1](https://github.com/OpenVoiceOS/ovos-utterance-plugin-cancel/tree/0.3.0a1) (2026-05-25)

[Full Changelog](https://github.com/OpenVoiceOS/ovos-utterance-plugin-cancel/compare/0.2.7a1...0.2.7a2)
[Full Changelog](https://github.com/OpenVoiceOS/ovos-utterance-plugin-cancel/compare/0.2.9a1...0.3.0a1)

**Merged pull requests:**

- ci: modernize workflows, add pyproject.toml, type hints and tests [\#28](https://github.com/OpenVoiceOS/ovos-utterance-plugin-cancel/pull/28) ([JarbasAl](https://github.com/JarbasAl))
- feat: cancel.blacklist veto for utterances \*about\* a cancel word \(partial \#7\) [\#35](https://github.com/OpenVoiceOS/ovos-utterance-plugin-cancel/pull/35) ([JarbasAl](https://github.com/JarbasAl))

## [0.2.7a1](https://github.com/OpenVoiceOS/ovos-utterance-plugin-cancel/tree/0.2.7a1) (2026-04-03)
## [0.2.9a1](https://github.com/OpenVoiceOS/ovos-utterance-plugin-cancel/tree/0.2.9a1) (2026-05-25)

[Full Changelog](https://github.com/OpenVoiceOS/ovos-utterance-plugin-cancel/compare/0.2.6a1...0.2.7a1)
[Full Changelog](https://github.com/OpenVoiceOS/ovos-utterance-plugin-cancel/compare/0.2.8...0.2.9a1)

**Merged pull requests:**

- fix\(i18n\): normalize locale folders to canonical BCP-47 [\#26](https://github.com/OpenVoiceOS/ovos-utterance-plugin-cancel/pull/26) ([JarbasAl](https://github.com/JarbasAl))

## [0.2.6a1](https://github.com/OpenVoiceOS/ovos-utterance-plugin-cancel/tree/0.2.6a1) (2025-11-24)

[Full Changelog](https://github.com/OpenVoiceOS/ovos-utterance-plugin-cancel/compare/V0.2.5...0.2.6a1)

**Merged pull requests:**

- es-es/translate [\#24](https://github.com/OpenVoiceOS/ovos-utterance-plugin-cancel/pull/24) ([gitlocalize-app[bot]](https://github.com/apps/gitlocalize-app))

## [V0.2.5](https://github.com/OpenVoiceOS/ovos-utterance-plugin-cancel/tree/V0.2.5) (2025-07-31)

[Full Changelog](https://github.com/OpenVoiceOS/ovos-utterance-plugin-cancel/compare/0.2.5...V0.2.5)
- fix: cancel transformer not firing — entry-point group, lang lookup, locale typos [\#32](https://github.com/OpenVoiceOS/ovos-utterance-plugin-cancel/pull/32) ([JarbasAl](https://github.com/JarbasAl))



Expand Down
113 changes: 78 additions & 35 deletions ovos_utterance_plugin_cancel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,62 +22,98 @@
# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
import os
from os.path import join, dirname, isfile
from os.path import join, dirname
from functools import lru_cache
from typing import Dict, List, Optional, Tuple

from ovos_plugin_manager.templates.transformers import UtteranceTransformer
from ovos_spec_tools import LocaleResources, standardize_lang
from ovos_utils.log import LOG
from ovos_utils.lang import standardize_lang_tag
from ovos_utils.bracket_expansion import expand_template
from langcodes import closest_match


def _resolve_lang(context: Optional[Dict[str, object]],
default: str = "en-US") -> str:
"""Return the BCP-47 language tag for an UtteranceTransformer call.

``context`` is the OVOS-MSG-1 ``message.context`` dict the
transformer service hands to ``transform()``. Two sources may carry
a language:

1. ``context["lang"]`` — the top-level convenience key that
ovos-core's IntentService writes before invoking transformers
(``ovos_core/intent_services/service.py::_handle_transformers``).
This is the **current contract** between core and transformer
plugins; trust it when present.
2. ``context["session"]["lang"]`` — the normative session-carrier
field per OVOS-MSG-1 §4. Falls back here when a caller hands the
transformer a Message without going through ovos-core's
pre-processing (HiveMind relays, tests, alternative bus
clients).

The convenience top-level key is a known gap in the spec — the
transformer signature will likely grow an explicit ``lang`` kwarg
in a future revision, at which point this helper collapses to a
one-liner. Until then, the dual lookup keeps the plugin robust
against direct callers.

``dict.get(key, default)`` returns ``None`` for keys present with a
``None`` value (only a *missing* key triggers the default), so the
``or`` chain handles explicit ``None`` correctly.
"""
context = context or {}
session = context.get("session") or {}
lang = (context.get("lang")
or session.get("lang")
or default)
return standardize_lang(lang)


class NevermindPlugin(UtteranceTransformer):
"""Utterance transformer that drops utterances ending with a cancel phrase.

Cancel phrases are loaded from ``locale/<lang>/cancel.intent`` and
Cancel phrases are loaded from ``locale/<lang>/cancel.voc`` and
matched against the tail of each utterance. On a match the utterance
list is cleared and ``{"canceled": True, "cancel_word": <phrase>}`` is
added to the context dict so downstream components can react.
"""

def __init__(self, name: str = "ovos-utterance-cancel", priority: int = 15) -> None:
super().__init__(name, priority)
# OVOS-INTENT-2 resource loader. One instance serves every language
# the plugin ships; the language is a parameter of each load call.
# The default lang_resolver is `closest_lang` (OVOS-INTENT-2 §2.2
# smart fallback), gated on distance < 10.
self._resources = LocaleResources(
skill_locale=join(dirname(__file__), "locale"))

@lru_cache()
def get_cancel_words(self, lang: str = "en-US") -> List[str]:
"""Return the list of cancel phrases for *lang*.

Phrases are read from ``locale/<best_match>/cancel.intent``, expanded
via bracket-expansion, and deduplicated. The result is LRU-cached per
language tag for the lifetime of the process.
"""Return the cancel phrases for *lang* (``cancel.voc``)."""
try:
phrases = self._resources.load_vocabulary("cancel", lang)
except FileNotFoundError:
LOG.warning(f"cancel.voc not available for {lang}")
return []
return list({phrase.strip() for phrase in phrases if phrase.strip()})

Args:
lang: BCP-47 language tag (e.g. ``"en-US"``).
@lru_cache()
def get_cancel_blacklist(self, lang: str = "en-US") -> List[str]:
"""Return the *veto prefixes* for *lang* (``cancel.blacklist``).

Returns:
List of cancel phrases, or an empty list when no locale is close
enough (langcodes distance ≥ 10).
OVOS-INTENT-2 §4.3 defines ``.blacklist`` as a phrase set that
an engine consults to *exclude* matches. Here, utterances that
start with any phrase in ``cancel.blacklist`` bypass the cancel
suffix match — they are *about* a cancel word (define / spell /
pronounce / play / etc.) rather than commands to cancel.
Partial fix for issue #7. A missing ``cancel.blacklist`` is
non-fatal: the plugin falls back to the historic
"always check the suffix" behaviour.
"""
locale_dir = join(dirname(__file__), "locale")
langs = [l for l in os.listdir(locale_dir)
if isfile(join(locale_dir, l, "cancel.intent"))]
best_lang, score = closest_match(lang, langs)
# langcodes distance: 0 = same, 1-3 = minor regional, 4-10 = significant regional
if score < 10:
res_path = join(locale_dir, best_lang, "cancel.intent")
lines: List[str] = []
with open(res_path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
lines.extend(expand_template(line))
return list({l.strip() for l in lines if l.strip()})
LOG.warning(f"cancel.intent not available for {lang}")
return []
try:
phrases = self._resources.load_blacklist("cancel", lang)
except FileNotFoundError:
return []
return list({phrase.strip() for phrase in phrases if phrase.strip()})

def transform(
self,
Expand All @@ -86,6 +122,11 @@ def transform(
) -> Tuple[List[str], Dict[str, object]]:
"""Drop utterances that end with a cancel phrase.

Skipped when the utterance starts with a phrase listed in
``cancel.blacklist`` for the active language — partial veto for
the edge cases tracked in issue #7 (e.g. ``"say nevermind"``,
``"what is the opposite of nevermind"``).

Args:
utterances: Recognised utterance candidates.
context: Session context dict; ``lang`` key is used for locale
Expand All @@ -97,10 +138,12 @@ def transform(
``{"canceled": True, "cancel_word": <phrase>}``. Otherwise the
original utterances are returned unchanged with an empty dict.
"""
context = context or {}
lang = standardize_lang_tag(context.get("lang", "en-US"))
lang = _resolve_lang(context)
blacklist = self.get_cancel_blacklist(lang)
for nevermind in self.get_cancel_words(lang):
for utterance in utterances:
if any(utterance.startswith(p) for p in blacklist):
continue
if utterance.endswith(nevermind):
return [], {"canceled": True, "cancel_word": nevermind}
return utterances, {}
18 changes: 18 additions & 0 deletions ovos_utterance_plugin_cancel/locale/ca-ES/cancel.blacklist
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# OVOS-INTENT-2 §4.3 blacklist for the cancel transformer.
#
# Utterance prefixes that veto the cancel suffix match.
#
# When an utterance starts with one of these phrases, the cancel
# suffix match is skipped — even if the utterance happens to end with
# a word from cancel.voc. The utterance is *about* the cancel word
# (define it, spell it, pronounce it, ask after it) rather than a
# command to cancel.
(reprodueix|digues|lletreja|pronuncia|llegeix|repeteix|escriu)
(defineix|explica|tradueix)
digues-me (el|la|sobre|què|allò)
ensenya'm
com (ho fas|es|és|funciona)
què (és|són|fa|fan|vol dir)
quin és (el significat|el contrari|la traducció)
quina és (l'antònim|la traducció|la pronunciació)
per què (no|fas|fa|és)
23 changes: 8 additions & 15 deletions ovos_utterance_plugin_cancel/locale/ca-ES/cancel.intent
Original file line number Diff line number Diff line change
@@ -1,15 +1,8 @@
cancel·la això
cancel·la aquesta ordre
cancel·la esta ordre
cancel·la l'ordre
cancel·la s'ordre
cancel·la-ho
no importa això
no importa aquesta ordre
no importa esta ordre
oblida això
oblida aquesta ordre
oblida esta ordre
oblida l'ordre
oblida s'ordre
oblida-ho
# Documents the matching pattern: an utterance ending with a cancel
# phrase from cancel.voc, optionally with no leading utterance at all
# (a standalone cancel).
#
# The plugin currently does suffix-matching with the cancel.voc
# vocabulary; this .intent is informational, not consumed at runtime,
# and exists so the resource role is unambiguous in the locale folder.
[{utterance}] <cancel>
15 changes: 15 additions & 0 deletions ovos_utterance_plugin_cancel/locale/ca-ES/cancel.voc
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
cancel·la això
cancel·la aquesta ordre
cancel·la esta ordre
cancel·la l'ordre
cancel·la s'ordre
cancel·la-ho
no importa això
no importa aquesta ordre
no importa esta ordre
oblida això
oblida aquesta ordre
oblida esta ordre
oblida l'ordre
oblida s'ordre
oblida-ho
17 changes: 17 additions & 0 deletions ovos_utterance_plugin_cancel/locale/da-DK/cancel.blacklist
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# OVOS-INTENT-2 §4.3 blacklist for the cancel transformer.
#
# Utterance prefixes that veto the cancel suffix match.
#
# When an utterance starts with one of these phrases, the cancel
# suffix match is skipped — even if the utterance happens to end with
# a word from cancel.voc. The utterance is *about* the cancel word
# (define it, spell it, pronounce it, ask after it) rather than a
# command to cancel.
(afspil|sig|stav|udtal|læs|gentag|skriv)
(definer|forklar|oversæt)
fortæl mig (om|hvad|hvordan)
lær mig
hvordan (gør du|skal man|er|udtaler du|staves)
hvad (er|betyder|hedder)
hvad er (betydningen|det modsatte|udtalen|oversættelsen)
hvorfor (gør du|gør du ikke|virker)
22 changes: 8 additions & 14 deletions ovos_utterance_plugin_cancel/locale/da-DK/cancel.intent
Original file line number Diff line number Diff line change
@@ -1,14 +1,8 @@
afbryd det
annulle kommando
annuller den kommando
annuller den ordre
annuller det
annuller ordre
glem den kommando
glem den ordre
glem det
glem kommando
glem ordre
ligeglad med den kommando
pyt med den rækkefølge
pyt med det
# Documents the matching pattern: an utterance ending with a cancel
# phrase from cancel.voc, optionally with no leading utterance at all
# (a standalone cancel).
#
# The plugin currently does suffix-matching with the cancel.voc
# vocabulary; this .intent is informational, not consumed at runtime,
# and exists so the resource role is unambiguous in the locale folder.
[{utterance}] <cancel>
14 changes: 14 additions & 0 deletions ovos_utterance_plugin_cancel/locale/da-DK/cancel.voc
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
afbryd det
annuller kommando
annuller den kommando
annuller den ordre
annuller det
annuller ordre
glem den kommando
glem den ordre
glem det
glem kommando
glem ordre
ligeglad med den kommando
pyt med den rækkefølge
pyt med det
17 changes: 17 additions & 0 deletions ovos_utterance_plugin_cancel/locale/de-DE/cancel.blacklist
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# OVOS-INTENT-2 §4.3 blacklist for the cancel transformer.
#
# Utterance prefixes that veto the cancel suffix match.
#
# When an utterance starts with one of these phrases, the cancel
# suffix match is skipped — even if the utterance happens to end with
# a word from cancel.voc. The utterance is *about* the cancel word
# (define it, spell it, pronounce it, ask after it) rather than a
# command to cancel.
(spiele|sage|sag|buchstabiere|sprich|lies|wiederhole|schreib|tippe)
(definiere|erkläre|übersetze)
(sag|erzähl) mir (was|wie|über|das|die|den)
(bring mir bei|lehre mich)
wie (sagt man|spricht man|schreibt man|buchstabiert man|funktioniert)
was (ist|sind|bedeutet|heißt)
was ist (die bedeutung|das gegenteil|die übersetzung|die aussprache)
warum (machst du|tust du nicht|funktioniert)
Loading