Skip to content

Commit 01d7ea1

Browse files
committed
refactor: source required_slots from INTENT-4 §10 manifest instead of __required_slots__
The orchestrator backstop for required_slots (PIPELINE-1 §6.2) was reading from a made-up match_data.__required_slots__ convention that no plugin implements. Per spec, required_slots is an optional field in ovos.intent.register.template broadcasts (INTENT-4 §6.1), stored in the orchestrator's manifest (INTENT-4 §10) as part of the full registration payload. - Remove __required_slots__ convention entirely - _missing_required_slots now reads from IntentManifest.definition via _manifest_required_slots() - Converted from staticmethod to instance method to access self.intent_manifest - Tests rewritten to register template intents on the manifest rather than injecting __required_slots__ into match_data
1 parent 1fb6c1f commit 01d7ea1

2 files changed

Lines changed: 116 additions & 57 deletions

File tree

ovos_core/intent_services/service.py

Lines changed: 40 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -444,8 +444,7 @@ def _dispatch_match(self, match: IntentHandlerMatch, message: Message, lang: str
444444

445445
return sess
446446

447-
@staticmethod
448-
def _missing_required_slots(match: IntentHandlerMatch) -> List[str]:
447+
def _missing_required_slots(self, match: IntentHandlerMatch) -> List[str]:
449448
"""OVOS-PIPELINE-1 §6.2 orchestrator backstop for ``required_slots``.
450449
451450
After a plugin returns a ``Match``, the orchestrator MUST verify that
@@ -462,39 +461,55 @@ def _missing_required_slots(match: IntentHandlerMatch) -> List[str]:
462461
Source of ``required_slots``: per §6.2 the orchestrator obtains this
463462
"from the same registration data the plugin consumed — in-process, this
464463
is available from the plugin's compiled state or from the orchestrator's
465-
own manifest (INTENT-4 §10)". ovos-core does not yet maintain an INTENT-4
466-
§10 registration manifest (no ``ovos.intent.list`` / ``ovos.intent.describe``
467-
index is plumbed into the orchestrator), so the only conformant,
468-
in-process channel a plugin has to surface the matched intent's
469-
``required_slots`` to the orchestrator is the ``Match`` itself. We read
470-
it from ``match.match_data['__required_slots__']`` when present. The
471-
captured slot map is ``match.match_data`` (OVOS-INTENT-3 §7; ``Match.slots``
472-
in PIPELINE-1 §4.3), keyed by slot/vocabulary name.
473-
474-
A slot is "absent" when its key is missing from the slot map — a present
475-
slot carrying a falsy value (e.g. ``0`` or ``False``) is a legitimate,
476-
present slot and MUST NOT be filtered out.
477-
478-
When a plugin does not surface ``required_slots`` (the common case
479-
today), this returns an empty list and the backstop is a no-op, leaving
480-
engine-side enforcement authoritative.
481-
482-
TODO: once an INTENT-4 §10 manifest is plumbed into the orchestrator,
483-
source ``required_slots`` from it (keyed on skill_id/intent_name/lang)
484-
so the backstop covers every plugin regardless of whether it echoes the
485-
constraint back in ``match_data``.
464+
own manifest (INTENT-4 §10)". ovos-core now maintains an INTENT-4 §10
465+
``IntentManifest`` (``ovos.intent.list`` / ``ovos.intent.describe``)
466+
plumbed into ``IntentService`` as ``self.intent_manifest``.
467+
468+
The manifest stores the full §5/§6 registration payload "as it was
469+
broadcast" (§10.2). When a template intent registration (INTENT-4 §6.1)
470+
carries an optional ``required_slots`` array, the manifest preserves it
471+
in ``definition["required_slots"]``. This method reads ``required_slots``
472+
from the manifest entry matching ``skill_id`` / ``intent_name``.
473+
474+
A slot is "absent" when its key is missing from the slot map —
475+
a present slot carrying a falsy value (e.g. ``0`` or ``False``)
476+
is a legitimate, present slot and MUST NOT be filtered out.
477+
478+
When the intent has no ``required_slots`` declaration this returns
479+
an empty list and the backstop is a no-op, leaving engine-side
480+
enforcement authoritative.
486481
487482
Returns:
488483
List[str]: required slot names absent from the match's slot map.
489484
Empty when the match is conformant or carries no
490485
``required_slots``.
491486
"""
492-
match_data = match.match_data or {}
493-
required_slots = match_data.get("__required_slots__")
487+
required_slots = self._manifest_required_slots(match)
494488
if not required_slots:
495489
return []
490+
match_data = match.match_data or {}
496491
return [slot for slot in required_slots if slot not in match_data]
497492

493+
def _manifest_required_slots(self, match: IntentHandlerMatch) -> List[str]:
494+
"""Look up ``required_slots`` from the INTENT-4 §10 manifest.
495+
496+
The manifest indexes every ``ovos.intent.register.template`` broadcast
497+
(INTENT-4 §6.1), whose payload MAY carry an optional ``required_slots``
498+
array. This method retrieves it keyed by ``skill_id`` / ``intent_name``.
499+
500+
Returns:
501+
List[str]: required slots from the manifest, or empty list.
502+
"""
503+
if not self.intent_manifest or not match.skill_id or not match.match_type:
504+
return []
505+
intent_name = match.match_type.split(":")[-1]
506+
for entry in self.intent_manifest._index.values():
507+
if entry["skill_id"] == match.skill_id and entry["intent_name"] == intent_name:
508+
req = (entry.get("definition") or {}).get("required_slots")
509+
if req:
510+
return req
511+
return []
512+
498513
@staticmethod
499514
def _upload_match_data(utterance: str, intent: str, lang: str, match_data: dict):
500515
"""if enabled upload the intent match data to a server, allowing users and developers

test/unittests/test_intent_service_extended.py

Lines changed: 76 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -630,69 +630,113 @@ def fake_emit(m):
630630
class TestRequiredSlotsBackstop(unittest.TestCase):
631631
"""OVOS-PIPELINE-1 §6.2 orchestrator backstop for required_slots."""
632632

633+
SKILL_ID = "music.skill"
634+
INTENT_NAME = "play_music"
635+
MATCH_TYPE = f"{SKILL_ID}:{INTENT_NAME}"
636+
637+
def setUp(self):
638+
self.svc = _make_service()
639+
640+
def _register_template(self, skill_id=None, intent_name=None,
641+
required_slots=None):
642+
"""Register a template intent on the manifest (INTENT-4 §6.1)."""
643+
msg = Message(
644+
"ovos.intent.register.template",
645+
{
646+
"skill_id": skill_id or self.SKILL_ID,
647+
"intent_name": intent_name or self.INTENT_NAME,
648+
"lang": "en-US",
649+
"samples": ["play {query}"],
650+
"required_slots": required_slots or [],
651+
},
652+
{"skill_id": skill_id or self.SKILL_ID},
653+
)
654+
self.svc.intent_manifest._on_register(msg)
655+
633656
@staticmethod
634-
def _match_with_required(present_slots, required_slots):
635-
data = {"skill_id": "music.skill"}
636-
data.update(present_slots)
637-
data["__required_slots__"] = required_slots
657+
def _make_match(slot_map=None, skill_id=SKILL_ID, match_type=MATCH_TYPE):
658+
"""Create a match with given slot map (no __required_slots__)."""
659+
data = {"skill_id": skill_id}
660+
if slot_map:
661+
data.update(slot_map)
638662
return IntentHandlerMatch(
639-
match_type="music.skill:play_music",
663+
match_type=match_type,
640664
match_data=data,
641-
skill_id="music.skill",
665+
skill_id=skill_id,
642666
utterance="play some jazz",
643667
)
644668

645669
# --- _missing_required_slots unit ---------------------------------------
646670

647671
def test_no_required_slots_is_noop(self):
648-
"""A match that carries no required_slots is never filtered."""
672+
"""No registered required_slots → backstop is a no-op."""
649673
match = _make_match()
650-
self.assertEqual(IntentService._missing_required_slots(match), [])
674+
self.assertEqual(self.svc._missing_required_slots(match), [])
651675

652676
def test_all_required_present_passes(self):
653-
match = self._match_with_required({"query": "some jazz"}, ["query"])
654-
self.assertEqual(IntentService._missing_required_slots(match), [])
677+
"""All declared required_slots present in the slot map."""
678+
self._register_template(required_slots=["query"])
679+
match = self._make_match({"query": "some jazz"})
680+
self.assertEqual(self.svc._missing_required_slots(match), [])
655681

656682
def test_missing_required_slot_detected(self):
657-
match = self._match_with_required({}, ["query"])
658-
self.assertEqual(IntentService._missing_required_slots(match), ["query"])
683+
"""A required slot absent from the slot map is reported."""
684+
self._register_template(required_slots=["query"])
685+
match = self._make_match({})
686+
self.assertEqual(self.svc._missing_required_slots(match), ["query"])
659687

660688
def test_falsy_present_value_counts_as_present(self):
661-
"""Spec/CodeRabbit fix: a present slot carrying a falsy value (0/False/
662-
empty string) is a legitimate, PRESENT slot and MUST NOT be filtered out —
663-
only a truly-abSENT key is missing."""
689+
"""A falsy value (0/False/'') is still a PRESENT slot — only a missing
690+
key is absent (INTENT-3 §5.3)."""
691+
self._register_template(required_slots=["query"])
664692
for present in ("", 0, False):
665693
with self.subTest(present=present):
666-
match = self._match_with_required({"query": present}, ["query"])
667-
self.assertEqual(IntentService._missing_required_slots(match), [],
694+
match = self._make_match({"query": present})
695+
self.assertEqual(self.svc._missing_required_slots(match), [],
668696
f"falsy value {present!r} is present, not missing")
669697

670698
def test_partial_required_slots_reported(self):
671-
match = self._match_with_required({"query": "jazz"},
672-
["query", "engine"])
673-
self.assertEqual(IntentService._missing_required_slots(match),
674-
["engine"])
675-
676-
def test_none_match_data_is_safe(self):
677-
match = IntentHandlerMatch(match_type="x:y", match_data=None,
678-
skill_id="x")
679-
self.assertEqual(IntentService._missing_required_slots(match), [])
699+
"""Only the slots missing from the slot map are reported."""
700+
self._register_template(required_slots=["query", "engine"])
701+
match = self._make_match({"query": "jazz"})
702+
self.assertEqual(self.svc._missing_required_slots(match), ["engine"])
703+
704+
def test_none_match_data_reports_all_as_missing(self):
705+
"""match_data=None means no slots were extracted — every required slot
706+
declared in the manifest is absent."""
707+
self._register_template(skill_id="x", intent_name="y",
708+
required_slots=["q"])
709+
match = IntentHandlerMatch(match_type="x:y", match_data=None, skill_id="x")
710+
self.assertEqual(self.svc._missing_required_slots(match), ["q"])
711+
712+
def test_skill_with_no_manifest_entry_is_noop(self):
713+
"""A match from a skill whose intent is not in the manifest is not
714+
filtered — no registration data, no required_slots to enforce."""
715+
match = _make_match()
716+
self.assertEqual(self.svc._missing_required_slots(match), [])
680717

681718
# --- end-to-end match-loop behaviour ------------------------------------
682719

683-
def _run_loop(self, first_match):
720+
def _run_loop(self, first_match, register_intent=True):
684721
"""Drive handle_utterance with a single pipeline stage returning
685722
``first_match``, then a second stage that always declines, and return
686723
whether the first match was dispatched and whether the no-match path
687724
was reached."""
688725
svc = _make_service()
726+
if register_intent:
727+
svc.intent_manifest._on_register(Message(
728+
"ovos.intent.register.template",
729+
{"skill_id": self.SKILL_ID,
730+
"intent_name": self.INTENT_NAME,
731+
"lang": "en-US",
732+
"samples": ["play {query}"],
733+
"required_slots": ["query"]},
734+
{"skill_id": self.SKILL_ID},
735+
))
689736
sess = Session("s")
690737
emitted = []
691738
svc.bus.emit = lambda m: emitted.append(m)
692739
svc.send_complete_intent_failure = MagicMock()
693-
# The §6.2 backstop runs strictly BEFORE _dispatch_match, so stub
694-
# dispatch out: a match that survives the backstop reaches it, a filtered
695-
# one never does.
696740
dispatched = []
697741

698742
def fake_dispatch(match, message, lang, pipeline_id=None):
@@ -716,13 +760,13 @@ def test_match_missing_required_slot_is_skipped_to_no_match(self):
716760
"""§6.2: a match missing a required slot is treated as if the plugin
717761
declined; iteration continues to no-match (send_complete_intent_failure
718762
emitted ovos.intent.unmatched)."""
719-
match = self._match_with_required({}, ["query"])
763+
match = self._make_match({})
720764
dispatched, _, unmatched = self._run_loop(match)
721765
self.assertFalse(dispatched, "match missing required_slots must not dispatch")
722766
self.assertTrue(unmatched, "missing required_slots must reach the no-match path")
723767

724768
def test_match_with_all_required_slots_dispatches(self):
725-
match = self._match_with_required({"query": "jazz"}, ["query"])
769+
match = self._make_match({"query": "jazz"})
726770
dispatched, _, unmatched = self._run_loop(match)
727771
self.assertTrue(dispatched, "conformant match must dispatch")
728772
self.assertFalse(unmatched, "conformant match must not fall through to no-match")

0 commit comments

Comments
 (0)