Skip to content

Commit 2a9bfbf

Browse files
JarbasAlclaude
andauthored
feat: emit OVOS-INTENT-2 §4.3 entity/slot blacklist on registration (#454)
Load the sibling `<entity>.blacklist` / `<slot>.blacklist` locale files and carry their slot-value exclusions in the registration payloads so intent engines can drop blacklisted slot values (e.g. a `person.blacklist` of pronouns keeps "he" out of `{person}`). - register_entity_file loads `<entity>.blacklist` and emits it as `blacklist` on the `padatious:register_entity` payload. - register_intent_file scans each `{slot}` the template declares, loads the sibling `<slot>.blacklist`, and emits `slot_blacklist` keyed by slot name on the `padatious:register_intent` payload. Both fields default empty (list / dict), keeping the payload shape-stable and backward compatible. Mirrors the existing `<intent>.blacklist` suppression. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3c8b7d2 commit 2a9bfbf

8 files changed

Lines changed: 120 additions & 16 deletions

File tree

ovos_workshop/intents.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from os.path import exists
22
from threading import RLock
3-
from typing import List, Optional
3+
from typing import Dict, List, Optional
44
import warnings
55
from ovos_bus_client.message import Message, dig_for_message
66
from ovos_bus_client.util import get_mycroft_bus
@@ -270,13 +270,19 @@ def remove_adapt_context(self, context: str):
270270
self.bus.emit(msg.forward('remove_context', {'context': context}))
271271

272272
def register_padatious_intent(self, intent_name: str, filename: str,
273-
lang: str, string_blacklist: Optional[List[str]] = None):
273+
lang: str, string_blacklist: Optional[List[str]] = None,
274+
slot_blacklist: Optional[Dict[str, List[str]]] = None):
274275
"""
275276
Register a Padatious intent file with the intent service.
276277
@param intent_name: Unique intent identifier
277278
(usually `skill_id`:`filename`)
278279
@param filename: Absolute file path to entity file
279280
@param lang: BCP-47 language code of registered intent
281+
@param string_blacklist: OVOS-INTENT-2 §4.3 phrases that suppress this
282+
intent from matching (the sibling `<intent>.blacklist`).
283+
@param slot_blacklist: OVOS-INTENT-2 §4.3 slot-value exclusions keyed by
284+
slot name, ``{slot: [excluded phrases]}`` — values that MUST NOT
285+
fill the named ``{slot}`` (each slot's sibling `<slot>.blacklist`).
280286
"""
281287
if not isinstance(filename, str):
282288
raise ValueError('Filename path must be a string')
@@ -289,21 +295,26 @@ def register_padatious_intent(self, intent_name: str, filename: str,
289295
"samples": samples,
290296
'name': intent_name,
291297
'lang': lang,
292-
'blacklisted_words': string_blacklist}
298+
'blacklisted_words': string_blacklist,
299+
'slot_blacklist': slot_blacklist or {}}
293300
msg = dig_for_message() or Message("")
294301
if "skill_id" not in msg.context:
295302
msg.context["skill_id"] = self.skill_id
296303
self.bus.emit(msg.forward("padatious:register_intent", data))
297304
self.registered_intents.append((intent_name.split(':')[-1], data))
298305

299306
def register_padatious_entity(self, entity_name: str, filename: str,
300-
lang: str):
307+
lang: str,
308+
blacklist: Optional[List[str]] = None):
301309
"""
302310
Register a Padatious entity file with the intent service.
303311
@param entity_name: Unique entity identifier
304312
(usually `skill_id`:`filename`)
305313
@param filename: Absolute file path to entity file
306314
@param lang: BCP-47 language code of registered intent
315+
@param blacklist: OVOS-INTENT-2 §4.3 slot-value exclusions — phrases
316+
that MUST NOT fill the ``{slot}`` this entity supplies (the sibling
317+
`<entity>.blacklist`).
307318
"""
308319
if not isinstance(filename, str):
309320
raise ValueError('Filename path must be a string')
@@ -319,7 +330,8 @@ def register_padatious_entity(self, entity_name: str, filename: str,
319330
{'file_name': filename,
320331
"samples": samples,
321332
'name': entity_name,
322-
'lang': lang}))
333+
'lang': lang,
334+
'blacklist': blacklist or []}))
323335

324336
def get_intent_names(self):
325337
log_deprecation("Reference `intent_names` directly", "0.1.0")

ovos_workshop/skills/ovos.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1346,12 +1346,28 @@ def register_intent_file(self, intent_file: str, handler: callable,
13461346
for enty in voc_blacklist or []:
13471347
disallowed_strings += self.voc_list(enty, lang=lang)
13481348

1349-
# OVOS-INTENT-2: a sibling "<intent>.blacklist" locale file lists
1350-
# slot-free phrases that should suppress this intent from matching
1349+
# OVOS-INTENT-2 §4.3: a sibling "<intent>.blacklist" locale file
1350+
# lists slot-free phrases that should suppress this intent from
1351+
# matching
13511352
blacklist_name = intent_file.rsplit(".", 1)[0]
13521353
disallowed_strings += resources.load_blacklist_file(blacklist_name)
13531354

1354-
self.intent_service.register_padatious_intent(name, filename, lang, string_blacklist=disallowed_strings)
1355+
# OVOS-INTENT-2 §4.3: for each "{slot}" the template declares, a
1356+
# sibling "<slot>.blacklist" locale file lists slot-value
1357+
# exclusions — values that MUST NOT bind to that slot (the
1358+
# canonical use is keeping anaphoric pronouns out of a referential
1359+
# slot). Keyed by slot name so consuming engines can drop them.
1360+
slot_blacklist = {}
1361+
with open(filename) as f:
1362+
slots = set(re.findall(r"{(.+?)}", f.read()))
1363+
for slot in slots:
1364+
phrases = resources.load_blacklist_file(slot)
1365+
if phrases:
1366+
slot_blacklist[slot] = phrases
1367+
1368+
self.intent_service.register_padatious_intent(
1369+
name, filename, lang, string_blacklist=disallowed_strings,
1370+
slot_blacklist=slot_blacklist)
13551371
if handler:
13561372
self.add_event(name, handler, 'mycroft.skill.handler',
13571373
activation=True, is_intent=True)
@@ -1383,7 +1399,13 @@ def register_entity_file(self, entity_file: str):
13831399
filename = str(entity.file_path)
13841400
name = f"{self.skill_id}:{basename(entity_file)}_" \
13851401
f"{md5(entity_file.encode('utf-8')).hexdigest()}"
1386-
self.intent_service.register_padatious_entity(name, filename, lang)
1402+
# OVOS-INTENT-2 §4.3: a sibling "<entity>.blacklist" locale file
1403+
# lists slot-free phrases that MUST NOT fill the {slot} this entity
1404+
# supplies (e.g. a "person.blacklist" of pronouns keeps "he" out of
1405+
# the {person} slot)
1406+
blacklist = resources.load_blacklist_file(entity_file)
1407+
self.intent_service.register_padatious_entity(name, filename, lang,
1408+
blacklist=blacklist)
13871409

13881410
def register_vocabulary(self, entity: str, entity_type: str,
13891411
lang: Optional[str] = None):
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# pronouns must not fill {person}
2+
he
3+
she
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
alice
2+
bob
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# values that must not fill {thing}
2+
the news
3+
the alarm

test/unittests/skills/test_base.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,7 @@ def test_register_intent_file(self):
443443
skill.config_core["secondary_langs"] = []
444444
skill.register_intent_file("time.intent", Mock(__name__="test"))
445445
skill.intent_service.register_padatious_intent.assert_called_once_with(
446-
f"{skill.skill_id}:time.intent", en_intent_file, "en-US", string_blacklist=[])
446+
f"{skill.skill_id}:time.intent", en_intent_file, "en-US", string_blacklist=[], slot_blacklist={})
447447

448448
# With secondary language
449449
skill.intent_service.register_padatious_intent.reset_mock()
@@ -452,9 +452,9 @@ def test_register_intent_file(self):
452452
self.assertEqual(
453453
skill.intent_service.register_padatious_intent.call_count, 2)
454454
skill.intent_service.register_padatious_intent.assert_any_call(
455-
f"{skill.skill_id}:time.intent", en_intent_file, "en-US", string_blacklist=[])
455+
f"{skill.skill_id}:time.intent", en_intent_file, "en-US", string_blacklist=[], slot_blacklist={})
456456
skill.intent_service.register_padatious_intent.assert_any_call(
457-
f"{skill.skill_id}:time.intent", uk_intent_file, "uk-UA", string_blacklist=[])
457+
f"{skill.skill_id}:time.intent", uk_intent_file, "uk-UA", string_blacklist=[], slot_blacklist={})
458458

459459
def test_register_entity_file(self):
460460
skill = OVOSSkill(bus=self.bus, skill_id=self.skill_id)
@@ -470,7 +470,7 @@ def test_register_entity_file(self):
470470
skill.register_entity_file("dow")
471471
skill.intent_service.register_padatious_entity.assert_called_once_with(
472472
f"{skill.skill_id}:dow_d446b2a6e46e7d94cdf7787e21050ff9",
473-
en_file, "en-US")
473+
en_file, "en-US", blacklist=[])
474474

475475
# With secondary language
476476
skill.intent_service.register_padatious_entity.reset_mock()
@@ -480,10 +480,10 @@ def test_register_entity_file(self):
480480
skill.intent_service.register_padatious_entity.call_count, 2)
481481
skill.intent_service.register_padatious_entity.assert_any_call(
482482
f"{skill.skill_id}:dow_d446b2a6e46e7d94cdf7787e21050ff9",
483-
en_file, "en-US")
483+
en_file, "en-US", blacklist=[])
484484
skill.intent_service.register_padatious_entity.assert_any_call(
485485
f"{skill.skill_id}:dow_d446b2a6e46e7d94cdf7787e21050ff9",
486-
uk_file, "uk-UA")
486+
uk_file, "uk-UA", blacklist=[])
487487

488488
def test_handle_enable_intent(self):
489489
# TODO

test/unittests/test_intent_service_interface.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ def test_register_intent(self):
140140

141141
intent_service.register_padatious_intent('test', filename, lang='en-US')
142142
expected_data = {'file_name': '/tmp/test.intent', 'lang': 'en-US', 'name': 'test',
143-
'samples': ['this is a test', 'test the intent'], 'blacklisted_words': None}
143+
'samples': ['this is a test', 'test the intent'], 'blacklisted_words': None,
144+
'slot_blacklist': {}}
144145
self.check_emitter([expected_data])
145146

test/unittests/test_skill.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,64 @@ def test_voc_blacklist_param_still_merges(self):
191191
data = self._register_payload("foo.intent")
192192
self.assertIsNotNone(data)
193193
self.assertIn("turn on the news", data["blacklisted_words"])
194+
195+
196+
class TestSlotBlacklistFile(unittest.TestCase):
197+
"""OVOS-INTENT-2 §4.3: a sibling '<slot>.blacklist' / '<entity>.blacklist'
198+
locale file feeds slot-value exclusions into the entity/intent
199+
registration payload so engines can drop blacklisted slot values."""
200+
201+
def setUp(self):
202+
self.bus = FakeBus()
203+
self.bus.emitted_msgs = []
204+
205+
def get_msg(msg):
206+
self.bus.emitted_msgs.append(json.loads(msg))
207+
208+
self.bus.on("message", get_msg)
209+
210+
res_dir = f"{dirname(__file__)}/ovos_tskill_blacklist"
211+
self.skill = OVOSSkill(skill_id="blacklist.test", bus=self.bus,
212+
resources_dir=res_dir)
213+
214+
def _payload(self, msg_type, name_part):
215+
for msg in self.bus.emitted_msgs:
216+
if msg["type"] == msg_type and \
217+
name_part in msg["data"]["name"]:
218+
return msg["data"]
219+
return None
220+
221+
def test_entity_with_blacklist_file(self):
222+
self.bus.emitted_msgs = []
223+
self.skill.register_entity_file("person.entity")
224+
data = self._payload("padatious:register_entity", "person")
225+
self.assertIsNotNone(data)
226+
self.assertEqual(data["samples"], ["alice", "bob"])
227+
self.assertIn("he", data["blacklist"])
228+
self.assertIn("she", data["blacklist"])
229+
230+
def test_entity_without_blacklist_file(self):
231+
self.bus.emitted_msgs = []
232+
# bar has no sibling .blacklist -> empty, back-compat
233+
self.skill.register_entity_file("bar.entity")
234+
data = self._payload("padatious:register_entity", "bar")
235+
# bar.entity does not exist; nothing registered
236+
self.assertIsNone(data)
237+
238+
def test_intent_slot_blacklist_keyed_by_slot(self):
239+
self.bus.emitted_msgs = []
240+
# foo.intent declares {thing}; sibling thing.blacklist excludes values
241+
self.skill.register_intent_file("foo.intent", None)
242+
data = self._payload("padatious:register_intent", "foo.intent")
243+
self.assertIsNotNone(data)
244+
self.assertIn("thing", data["slot_blacklist"])
245+
self.assertIn("the news", data["slot_blacklist"]["thing"])
246+
self.assertIn("the alarm", data["slot_blacklist"]["thing"])
247+
248+
def test_intent_without_slot_blacklist(self):
249+
self.bus.emitted_msgs = []
250+
# bar.intent has no slots -> empty map, back-compat
251+
self.skill.register_intent_file("bar.intent", None)
252+
data = self._payload("padatious:register_intent", "bar.intent")
253+
self.assertIsNotNone(data)
254+
self.assertEqual(data["slot_blacklist"], {})

0 commit comments

Comments
 (0)