Skip to content

Commit 415cf20

Browse files
JarbasAlclaude
andcommitted
fix: collapse dual-registered intent aliases at registration time
Root cause of the session blacklist bypass: this plugin subscribes to both the legacy registration topic (name suffixed `<skill_id>:<file>.intent`) and the OVOS-INTENT-4 spec topic (`<skill_id>:<file>`, suffix-less), so one logical skill intent lands as TWO engine entries. Blacklisting one alias left the other matchable. This plugin owns its own back-compat for the legacy topic, so it now collapses the alias at REGISTRATION time instead of at compare time: - `register_intent` (legacy topic handler) now canonicalizes the intent name — strips a trailing `.intent` suffix — before indexing, so both wire messages land as a single engine entry, and only appends to the manifest when the canonical name is new (`handle_register_template` already replaced-in-place via `§8.1 replacement is implicit`). - `__detach_intent` canonicalizes too, so detaching by either alias works. - The engine (`padacioso.IntentContainer.add_intent`) keys intents by name in a dict, so re-registration under the canonical name was already an implicit replace, not a duplicate — no engine-level idempotency fix needed here (unlike the padatious plugin's list-based engine). - The session blacklist filter (`_calc_padacioso_intent`) no longer dealiases `i["name"]` — engine matches are canonical by construction now. It still canonicalizes the blacklist entries themselves, since old sessions/mycroft.conf may still list an intent by its legacy `.intent`-suffixed id. A one-time-per-entry LOG.warning flags any such legacy blacklist entry as deprecated, naming the offending entry and its canonical replacement, so operators can update their config. Tests: TestSessionBlacklistAlias (compare-time suppression, either alias) kept/updated; new TestRegistrationCollapse + TestSessionBlacklistCanonicalization in test/test_registration_collapse.py cover the registration-time collapse (single manifest entry, detach-by-legacy-name, deprecation warning). test_malformed_samples.py updated: the warning/index now names the canonicalized (alias-collapsed) intent name. Known gap (see the same-shape padatious plugin PR for full detail): ovos-workshop's register_intent_file() binds a skill's dispatch handler only to the legacy `<skill_id>:<file>.intent` topic, not the spec canonical one, so an orchestrator dispatching the canonicalized name no longer reaches that handler. Not fixable in this plugin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c5ca2db commit 415cf20

4 files changed

Lines changed: 246 additions & 3 deletions

File tree

padacioso/opm.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,10 @@ def __detach_intent(self, intent_name):
199199
Args:
200200
intent_name (str): intent identifier
201201
"""
202+
# Detach/removal must key off the same canonical name registration
203+
# collapsed onto, so unregistering by either the legacy `.intent`
204+
# alias or the OVOS-INTENT-4 canonical id works (ovos-core#831).
205+
intent_name = _dealias_intent_name(intent_name)
202206
if intent_name in self.registered_intents:
203207
self.registered_intents.remove(intent_name)
204208
self._intent_context_gates.pop(intent_name, None)
@@ -306,6 +310,15 @@ def register_intent(self, message):
306310
Args:
307311
message (Message): message triggering action
308312
"""
313+
# ovos-workshop >= 9.3 dual-registers one logical intent under both
314+
# the legacy ``padatious:register_intent`` contract (name suffixed
315+
# ``.intent``) and the OVOS-INTENT-4 spec contract (suffix-less,
316+
# routed via handle_register_template). Collapse the alias to the
317+
# canonical name HERE, at registration time, so both wire messages
318+
# index a single engine entry instead of two matchable duplicates
319+
# (ovos-core#831). This plugin owns its own back-compat.
320+
message.data['name'] = _dealias_intent_name(message.data['name'])
321+
309322
lang = message.data.get('lang', self.lang)
310323
lang = standardize_lang(lang)
311324
if lang in self.containers:
@@ -319,7 +332,11 @@ def register_intent(self, message):
319332
raise
320333
registered = True
321334
if registered:
322-
self.registered_intents.append(message.data['name'])
335+
# §8.1 replacement is implicit: a re-registration of the same
336+
# canonical name replaces the prior manifest entry rather
337+
# than stacking a duplicate (mirrors handle_register_template).
338+
if message.data['name'] not in self.registered_intents:
339+
self.registered_intents.append(message.data['name'])
323340
self._store_context_gate(message.data['name'], message.data)
324341

325342
def register_entity(self, message):
@@ -588,6 +605,56 @@ def shutdown(self):
588605
self.bus.remove(SpecMessage.INTENT_DISABLE.value, self.handle_disable_intent)
589606

590607

608+
def _dealias_intent_name(name: Optional[str]) -> Optional[str]:
609+
"""Fold the legacy ``<skill_id>:<file>.intent`` id onto the OVOS-INTENT-4
610+
canonical ``<skill_id>:<file>`` id.
611+
612+
ovos-workshop >= 9.3 dual-registers one skill capability under both wire
613+
forms during the INTENT-4 migration (the legacy ``padatious:register_intent``
614+
contract and the spec ``ovos.intent.register.template`` contract, whose
615+
``intent_name`` already has the ``.intent`` suffix stripped). This plugin
616+
folds that onto one canonical engine entry at REGISTRATION time (see
617+
``PadaciosoPipeline.register_intent``/``handle_register_template``), so
618+
engine matches (``i["name"]``) are canonical by construction.
619+
620+
This helper is also used to canonicalize session ``blacklisted_intents``
621+
entries, since old sessions/configs may still carry the legacy
622+
``.intent``-suffixed id (ovos-core#831; OVOS-PIPELINE-1 §5.4).
623+
"""
624+
if name and name.endswith(".intent"):
625+
return name[:-len(".intent")]
626+
return name
627+
628+
629+
# Legacy `.intent`-suffixed blacklist entries are deprecated compat, not a
630+
# stable contract. Warn once per distinct offending entry (not per utterance)
631+
# so stale mycroft.conf/session config gets flagged without spamming the log.
632+
_warned_legacy_blacklist_entries = set()
633+
634+
635+
def _canonicalize_blacklist(blacklisted_intents: frozenset) -> frozenset:
636+
"""Canonicalize legacy `.intent`-suffixed session blacklist entries.
637+
638+
Sessions/config may still list intents by the legacy
639+
``<skill_id>:<file>.intent`` id. Engine matches are canonical by
640+
construction (registration-time alias collapse), so the blacklist must be
641+
normalized to compare correctly. Logs a one-time deprecation warning per
642+
distinct legacy entry pointing at the canonical replacement.
643+
"""
644+
canonical = set()
645+
for b in blacklisted_intents:
646+
c = _dealias_intent_name(b)
647+
canonical.add(c)
648+
if c != b and b not in _warned_legacy_blacklist_entries:
649+
_warned_legacy_blacklist_entries.add(b)
650+
LOG.warning(
651+
f"Session blacklisted_intents entry '{b}' uses the deprecated "
652+
f"legacy '.intent'-suffixed id; support for this alias will "
653+
f"be removed. Update mycroft.conf / session config to use the "
654+
f"canonical id '{c}' instead.")
655+
return frozenset(canonical)
656+
657+
591658
@lru_cache(maxsize=128) # covers burst of multiple ASR hypotheses without thrashing
592659
def _calc_padacioso_intent(utt: str,
593660
intent_container: FallbackIntentContainer,
@@ -602,6 +669,10 @@ def _calc_padacioso_intent(utt: str,
602669
@return: matched PadaciosoIntent
603670
"""
604671
try:
672+
blacklisted_intents = _canonicalize_blacklist(blacklisted_intents)
673+
# Matches are canonical by construction (registration-time alias
674+
# collapse, see PadaciosoPipeline.register_intent), so only the
675+
# blacklist needs canonicalizing here.
605676
intents = [i for i in intent_container.calc_intents(utt)
606677
if i is not None
607678
and i["name"] not in blacklisted_intents

test/test_malformed_samples.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ def test_malformed_sample_skipped_valid_indexed(self):
3838
'stop everything'])
3939
self.assertEqual(warn.call_count, 2)
4040
container = pipeline.containers['en-US']
41-
name = 'skill-persona.openvoiceos:cancel.intent'
41+
# registration-time alias collapse (ovos-core#831) canonicalizes the
42+
# legacy `.intent`-suffixed name before indexing
43+
name = 'skill-persona.openvoiceos:cancel'
4244
self.assertIn(name, container.intent_samples)
4345
result = container.calc_intent('stop everything')
4446
self.assertEqual(result['name'], name)
@@ -58,7 +60,9 @@ def test_warning_names_skill_intent_lang_topic(self):
5860
with mock.patch('padacioso.opm.LOG.warning') as warn:
5961
self._register(pipeline, ['{utterance}', 'stop everything'])
6062
logged = " ".join(str(c) for c in warn.call_args_list)
61-
for token in ('skill-persona.openvoiceos', 'cancel.intent',
63+
# the warning names the canonical (alias-collapsed) intent name,
64+
# since registration-time collapse happens before this log fires
65+
for token in ('skill-persona.openvoiceos', 'cancel',
6266
'en-US', 'padatious:register_intent'):
6367
self.assertIn(token, logged)
6468

test/test_ovoscope_e2e.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,5 +122,56 @@ def test_blacklisted_skill_is_skipped(self):
122122
self.expect_no_match("hello", session=sess, timeout=3.0)
123123

124124

125+
class TestSessionBlacklistAlias(_PadaciosoHarness):
126+
"""ovos-workshop >= 9.3 dual-registers each ``.intent`` file under both
127+
the legacy ``<skill_id>:<file>.intent`` id and the OVOS-INTENT-4
128+
canonical ``<skill_id>:<file>`` id (ovos-core#831). The plugin collapses
129+
that alias onto one canonical engine entry at REGISTRATION time (see
130+
``PadaciosoPipeline.register_intent``/``handle_register_template``), so
131+
a session blacklist entry naming either alias must suppress the single
132+
canonical match, per OVOS-PIPELINE-1 §5.4. Here both messages go through
133+
the legacy topic with different names to exercise the blacklist
134+
canonicalization path directly; ``test/test_registration_collapse.py``
135+
covers the registration-time collapse across both wire topics.
136+
"""
137+
138+
LEGACY_NAME = f"{_PadaciosoHarness.SKILL_ID}:hello.intent"
139+
NEW_NAME = f"{_PadaciosoHarness.SKILL_ID}:hello"
140+
141+
def _register_both_aliases(self):
142+
self._register_intent(self.LEGACY_NAME, _HELLO_SAMPLES)
143+
self._register_intent(self.NEW_NAME, _HELLO_SAMPLES)
144+
145+
def test_blacklisting_legacy_id_suppresses_new_alias(self):
146+
self._register_both_aliases()
147+
sess = make_session(
148+
"bl-alias-legacy-test",
149+
blacklisted_intents=[self.LEGACY_NAME],
150+
)
151+
self.expect_no_match("hello", session=sess, timeout=3.0)
152+
153+
def test_blacklisting_new_id_suppresses_legacy_alias(self):
154+
self._register_both_aliases()
155+
sess = make_session(
156+
"bl-alias-new-test",
157+
blacklisted_intents=[self.NEW_NAME],
158+
)
159+
self.expect_no_match("hello", session=sess, timeout=3.0)
160+
161+
def test_non_blacklisted_intent_still_matches(self):
162+
self._register_both_aliases()
163+
self._register_intent(f"{self.SKILL_ID}:bye", _BYE_SAMPLES)
164+
sess = make_session(
165+
"bl-alias-unrelated-test",
166+
blacklisted_intents=[f"{self.SKILL_ID}:bye"],
167+
)
168+
msg = self.send_and_capture(
169+
"hello",
170+
expected_types=[self.LEGACY_NAME, self.NEW_NAME],
171+
session=sess,
172+
)
173+
self.assertIsNotNone(msg)
174+
175+
125176
if __name__ == "__main__":
126177
unittest.main()

test/test_registration_collapse.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Registration-time alias collapse and blacklist canonicalization.
2+
3+
ovos-workshop >= 9.3 dual-registers one logical intent under both the
4+
legacy ``<skill_id>:<file>.intent`` id (via ``padatious:register_intent``)
5+
and the OVOS-INTENT-4 canonical ``<skill_id>:<file>`` id (via
6+
``ovos.intent.register.template`` -> ``handle_register_template``). This
7+
plugin owns collapsing that alias at REGISTRATION time so both wire
8+
contracts land as a single engine entry (ovos-core#831).
9+
10+
These tests cover:
11+
- both registration messages collapse to exactly one manifest entry
12+
- detaching by the legacy name removes the collapsed entry
13+
- the session blacklist filter still canonicalizes legacy-named entries
14+
(since old sessions/config may carry them) and warns once per entry
15+
"""
16+
import unittest
17+
from unittest import mock
18+
19+
from ovos_bus_client.message import Message
20+
from ovos_utils.fakebus import FakeBus
21+
22+
from padacioso.opm import PadaciosoPipeline, _warned_legacy_blacklist_entries
23+
24+
SKILL_ID = "collapse.skill"
25+
LEGACY_NAME = f"{SKILL_ID}:hello.intent"
26+
NEW_NAME = f"{SKILL_ID}:hello"
27+
LANG = "en-US"
28+
SAMPLES = ["hello", "hi there", "hey"]
29+
30+
31+
def legacy_register_msg():
32+
return Message("padatious:register_intent", {
33+
"skill_id": SKILL_ID, "name": LEGACY_NAME, "lang": LANG,
34+
"samples": SAMPLES,
35+
}, {"skill_id": SKILL_ID})
36+
37+
38+
def spec_register_msg():
39+
return Message("ovos.intent.register.template", {
40+
"skill_id": SKILL_ID, "intent_name": "hello", "lang": LANG,
41+
"samples": SAMPLES,
42+
}, {"skill_id": SKILL_ID})
43+
44+
45+
class TestRegistrationCollapse(unittest.TestCase):
46+
def setUp(self):
47+
self.pipeline = PadaciosoPipeline(FakeBus())
48+
49+
def test_both_wire_contracts_collapse_to_one_manifest_entry(self):
50+
self.pipeline.register_intent(legacy_register_msg())
51+
self.pipeline.handle_register_template(spec_register_msg())
52+
53+
manifest = self.pipeline.registered_intents
54+
self.assertEqual(manifest.count(NEW_NAME), 1)
55+
self.assertNotIn(LEGACY_NAME, manifest)
56+
57+
def test_second_arrival_replaces_not_duplicates_engine_entry(self):
58+
self.pipeline.register_intent(legacy_register_msg())
59+
self.pipeline.handle_register_template(spec_register_msg())
60+
61+
container = self.pipeline.containers[LANG]
62+
# engine keys intents by name in a dict, so re-registration is
63+
# inherently a replace; assert there's exactly one entry present
64+
self.assertIn(NEW_NAME, container.intent_samples)
65+
self.assertNotIn(LEGACY_NAME, container.intent_samples)
66+
67+
def test_detach_by_legacy_name_removes_collapsed_entry(self):
68+
self.pipeline.register_intent(legacy_register_msg())
69+
self.pipeline.handle_register_template(spec_register_msg())
70+
self.assertIn(NEW_NAME, self.pipeline.registered_intents)
71+
72+
self.pipeline.handle_detach_intent(
73+
Message("detach_intent", {"intent_name": LEGACY_NAME}))
74+
75+
self.assertNotIn(NEW_NAME, self.pipeline.registered_intents)
76+
self.assertNotIn(NEW_NAME, self.pipeline.containers[LANG].intent_samples)
77+
78+
79+
class TestSessionBlacklistCanonicalization(unittest.TestCase):
80+
"""Matches are canonical by construction; only the blacklist entries
81+
(which may still carry legacy-named sessions) need canonicalizing."""
82+
83+
def setUp(self):
84+
_warned_legacy_blacklist_entries.clear()
85+
self.pipeline = PadaciosoPipeline(FakeBus())
86+
self.pipeline.register_intent(legacy_register_msg())
87+
self.pipeline.handle_register_template(spec_register_msg())
88+
89+
def test_legacy_named_blacklist_entry_suppresses_canonical_match(self):
90+
sess = mock.Mock()
91+
sess.blacklisted_intents = [LEGACY_NAME]
92+
sess.blacklisted_skills = []
93+
sess.intent_context = {}
94+
with mock.patch("padacioso.opm.SessionManager.get", return_value=sess):
95+
intent = self.pipeline.calc_intent(["hello"], lang=LANG)
96+
self.assertIsNone(intent)
97+
98+
def test_legacy_named_blacklist_entry_logs_deprecation_warning_once(self):
99+
sess = mock.Mock()
100+
sess.blacklisted_intents = [LEGACY_NAME]
101+
sess.blacklisted_skills = []
102+
sess.intent_context = {}
103+
from padacioso.opm import _calc_padacioso_intent
104+
_calc_padacioso_intent.cache_clear()
105+
with mock.patch("padacioso.opm.SessionManager.get", return_value=sess), \
106+
mock.patch("padacioso.opm.LOG.warning") as mock_warn:
107+
self.pipeline.calc_intent(["hello"], lang=LANG)
108+
self.pipeline.calc_intent(["hi there"], lang=LANG)
109+
110+
deprecation_calls = [c for c in mock_warn.call_args_list
111+
if LEGACY_NAME in str(c)]
112+
self.assertEqual(len(deprecation_calls), 1)
113+
self.assertIn(NEW_NAME, str(deprecation_calls[0]))
114+
115+
116+
if __name__ == "__main__":
117+
unittest.main()

0 commit comments

Comments
 (0)