Skip to content

Commit 43de0c7

Browse files
authored
Merge pull request #70 from OpenVoiceOS/fix/intent4-skip-malformed-templates
fix: skip malformed template samples instead of crashing registration
2 parents e936eed + 80e7e41 commit 43de0c7

2 files changed

Lines changed: 177 additions & 10 deletions

File tree

padacioso/opm.py

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
from ovos_bus_client.session import SessionManager, Session
1010
from ovos_config.config import Configuration
1111
from ovos_plugin_manager.templates.pipeline import ConfidenceMatcherPipeline, IntentHandlerMatch
12-
from ovos_spec_tools import closest_lang, standardize_lang, SpecMessage, gate_satisfied
12+
from ovos_spec_tools import closest_lang, standardize_lang, SpecMessage, gate_satisfied, \
13+
expand, MalformedTemplate
1314
from ovos_utils import flatten_list
1415
from ovos_utils.fakebus import FakeBus
1516
from ovos_utils.log import LOG, log_deprecation
@@ -241,13 +242,37 @@ def handle_detach_skill(self, message):
241242
if en["name"].startswith(skill_id_colon):
242243
self.__detach_entity(en["name"], en["lang"])
243244

244-
def _register_object(self, message, object_name, register_func):
245+
def _valid_samples(self, samples, topic, name, lang):
246+
"""Drop malformed template samples, keeping the valid ones.
247+
248+
OVOS-INTENT-4 §6.3/§5.3 — each sample that fails template expansion
249+
is skipped with a WARN naming the owning skill, the intent/entity,
250+
the lang, the topic and the reason; the remaining samples are still
251+
indexed. An empty return means the registration must be rejected.
252+
"""
253+
skill_id = name.split(':')[0] if ':' in name else None
254+
valid = []
255+
for sample in samples:
256+
try:
257+
expand(sample)
258+
valid.append(sample)
259+
except MalformedTemplate as e:
260+
LOG.warning(f"skipping malformed sample on {topic}: "
261+
f"skill_id={skill_id!r} name={name!r} "
262+
f"lang={lang!r} reason={e}")
263+
return valid
264+
265+
def _register_object(self, message, object_name, register_func, lang):
245266
"""Generic method for registering a padacioso object.
246267
247268
Args:
248269
message (Message): trigger for action
249270
object_name (str): type of entry to register
250271
register_func (callable): function to call for registration
272+
lang (str): standardized language of the registration
273+
274+
Returns:
275+
bool: True if something was registered
251276
"""
252277
file_name = message.data.get('file_name')
253278
samples = message.data.get("samples")
@@ -257,15 +282,23 @@ def _register_object(self, message, object_name, register_func):
257282

258283
if (not file_name or not isfile(file_name)) and not samples:
259284
LOG.error('Could not find file ' + file_name)
260-
return
285+
return False
261286

262287
if not samples and isfile(file_name):
263288
with open(file_name) as f:
264289
samples = [line.strip() for line in f.readlines()]
265290

291+
samples = self._valid_samples(samples, message.msg_type, name, lang)
292+
if not samples: # §6.3 — reject only when nothing valid remains
293+
LOG.warning(f"rejecting {object_name} registration on "
294+
f"{message.msg_type}: name={name!r} lang={lang!r} "
295+
f"reason=no valid samples remain")
296+
return False
297+
266298
register_func(name, samples)
267299
# the container was mutated; drop stale cached matches
268300
_calc_padacioso_intent.cache_clear()
301+
return True
269302

270303
def register_intent(self, message):
271304
"""Messagebus handler for registering intents.
@@ -276,16 +309,18 @@ def register_intent(self, message):
276309
lang = message.data.get('lang', self.lang)
277310
lang = standardize_lang(lang)
278311
if lang in self.containers:
279-
self.registered_intents.append(message.data['name'])
280-
self._store_context_gate(message.data['name'], message.data)
281312
try:
282-
self._register_object(message, 'intent',
283-
self.containers[lang].add_intent)
313+
registered = self._register_object(
314+
message, 'intent', self.containers[lang].add_intent, lang)
284315
except RuntimeError:
285316
name = message.data.get('name', "")
286317
# padacioso fails on reloading a skill, just ignore
287318
if name not in self.containers[lang].intent_samples:
288319
raise
320+
registered = True
321+
if registered:
322+
self.registered_intents.append(message.data['name'])
323+
self._store_context_gate(message.data['name'], message.data)
289324

290325
def register_entity(self, message):
291326
"""Messagebus handler for registering entities.
@@ -296,9 +331,9 @@ def register_entity(self, message):
296331
lang = message.data.get('lang', self.lang)
297332
lang = standardize_lang(lang)
298333
if lang in self.containers:
299-
self.registered_entities.append(message.data)
300-
self._register_object(message, 'entity',
301-
self.containers[lang].add_entity)
334+
if self._register_object(message, 'entity',
335+
self.containers[lang].add_entity, lang):
336+
self.registered_entities.append(message.data)
302337

303338
# ------------------------------------------------------------------
304339
# OVOS-INTENT-4 bus handlers (consumed alongside the legacy topics)
@@ -339,6 +374,10 @@ def handle_register_template(self, message: Message):
339374
return
340375

341376
name = self._internal_name(skill_id, intent_name)
377+
samples = self._valid_samples(samples, topic, name, lang)
378+
if not samples: # §6.3 — reject only when nothing valid remains
379+
self._warn_malformed(topic, data, "no valid samples remain")
380+
return
342381
# §8.1 replacement is implicit: a re-registration replaces the prior entry
343382
self.__detach_intent(name)
344383
self.registered_intents.append(name)
@@ -374,6 +413,10 @@ def handle_register_entity(self, message: Message):
374413
return
375414

376415
name = self._internal_name(skill_id, entity_name)
416+
samples = self._valid_samples(samples, topic, name, lang)
417+
if not samples: # §7.2 — reject only when nothing valid remains
418+
self._warn_malformed(topic, data, "no valid samples remain")
419+
return
377420
# §8.1 replacement is implicit
378421
self.__detach_entity(name, lang)
379422
self.registered_entities = [

test/test_malformed_samples.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Malformed template samples must never crash intent registration.
2+
3+
OVOS-INTENT-4 §6.3/§5.3: consumers skip malformed samples with a warning
4+
(naming skill, intent, lang, topic and reason), index the remaining valid
5+
samples, and reject the registration only when no valid sample remains —
6+
the executor never crashes.
7+
"""
8+
import unittest
9+
from unittest import mock
10+
11+
from ovos_bus_client.message import Message
12+
from ovos_utils.fakebus import FakeBus
13+
14+
from padacioso.opm import PadaciosoPipeline
15+
16+
17+
def _pipeline():
18+
return PadaciosoPipeline(FakeBus(), {"fuzz": False})
19+
20+
21+
class LegacyRegistrationMalformedSamplesTest(unittest.TestCase):
22+
"""Legacy ``padatious:register_intent`` path."""
23+
24+
def _register(self, pipeline, samples,
25+
name='skill-persona.openvoiceos:cancel.intent'):
26+
pipeline.register_intent(Message('padatious:register_intent',
27+
{'name': name,
28+
'lang': 'en-US',
29+
'samples': samples}))
30+
31+
def test_malformed_sample_skipped_valid_indexed(self):
32+
pipeline = _pipeline()
33+
with mock.patch('padacioso.opm.LOG.warning') as warn:
34+
# slot-only and unbalanced samples ride along a valid one,
35+
# as in released locale files with translated slot names
36+
self._register(pipeline, ['{utterance}',
37+
'cancel {rotina',
38+
'stop everything'])
39+
self.assertEqual(warn.call_count, 2)
40+
container = pipeline.containers['en-US']
41+
name = 'skill-persona.openvoiceos:cancel.intent'
42+
self.assertIn(name, container.intent_samples)
43+
result = container.calc_intent('stop everything')
44+
self.assertEqual(result['name'], name)
45+
46+
def test_all_samples_malformed_rejects_registration(self):
47+
pipeline = _pipeline()
48+
with mock.patch('padacioso.opm.LOG.warning') as warn:
49+
self._register(pipeline, ['{utterance}', '{other}'])
50+
self.assertTrue(warn.called)
51+
container = pipeline.containers['en-US']
52+
name = 'skill-persona.openvoiceos:cancel.intent'
53+
self.assertNotIn(name, container.intent_samples)
54+
self.assertNotIn(name, pipeline.registered_intents)
55+
56+
def test_warning_names_skill_intent_lang_topic(self):
57+
pipeline = _pipeline()
58+
with mock.patch('padacioso.opm.LOG.warning') as warn:
59+
self._register(pipeline, ['{utterance}', 'stop everything'])
60+
logged = " ".join(str(c) for c in warn.call_args_list)
61+
for token in ('skill-persona.openvoiceos', 'cancel.intent',
62+
'en-US', 'padatious:register_intent'):
63+
self.assertIn(token, logged)
64+
65+
def test_legacy_entity_malformed_sample_skipped(self):
66+
pipeline = _pipeline()
67+
with mock.patch('padacioso.opm.LOG.warning') as warn:
68+
pipeline.register_entity(
69+
Message('padatious:register_entity',
70+
{'name': 'skill-persona.openvoiceos:thing',
71+
'lang': 'en-US',
72+
'samples': ['ok value', 'broken {value']}))
73+
self.assertEqual(warn.call_count, 1)
74+
container = pipeline.containers['en-US']
75+
self.assertIn('skill-persona.openvoiceos:thing',
76+
container.entity_samples)
77+
78+
79+
class SpecRegistrationMalformedSamplesTest(unittest.TestCase):
80+
"""INTENT-4 ``ovos.intent.register.template`` / entity paths."""
81+
82+
def test_template_malformed_sample_skipped(self):
83+
pipeline = _pipeline()
84+
with mock.patch('padacioso.opm.LOG.warning') as warn:
85+
pipeline.handle_register_template(
86+
Message('ovos.intent.register.template',
87+
{'skill_id': 'skill-persona.openvoiceos',
88+
'intent_name': 'cancel',
89+
'lang': 'en-US',
90+
'samples': ['{utterance}', 'stop everything']}))
91+
self.assertEqual(warn.call_count, 1)
92+
name = pipeline._internal_name('skill-persona.openvoiceos', 'cancel')
93+
self.assertIn(name, pipeline.containers['en-US'].intent_samples)
94+
95+
def test_template_all_malformed_rejected(self):
96+
pipeline = _pipeline()
97+
with mock.patch('padacioso.opm.LOG.warning') as warn:
98+
pipeline.handle_register_template(
99+
Message('ovos.intent.register.template',
100+
{'skill_id': 'skill-persona.openvoiceos',
101+
'intent_name': 'cancel',
102+
'lang': 'en-US',
103+
'samples': ['{utterance}']}))
104+
self.assertTrue(warn.called)
105+
name = pipeline._internal_name('skill-persona.openvoiceos', 'cancel')
106+
self.assertNotIn(name, pipeline.containers['en-US'].intent_samples)
107+
self.assertNotIn(name, pipeline.registered_intents)
108+
109+
def test_entity_malformed_sample_skipped(self):
110+
pipeline = _pipeline()
111+
with mock.patch('padacioso.opm.LOG.warning') as warn:
112+
pipeline.handle_register_entity(
113+
Message('ovos.entity.register',
114+
{'skill_id': 'skill-persona.openvoiceos',
115+
'entity_name': 'thing',
116+
'lang': 'en-US',
117+
'samples': ['broken {value', 'good value']}))
118+
self.assertEqual(warn.call_count, 1)
119+
name = pipeline._internal_name('skill-persona.openvoiceos', 'thing')
120+
self.assertIn(name, pipeline.containers['en-US'].entity_samples)
121+
122+
123+
if __name__ == '__main__':
124+
unittest.main()

0 commit comments

Comments
 (0)