diff --git a/ovos_core/intent_services/manifest.py b/ovos_core/intent_services/manifest.py index 6a469ef9349..5601c6b37bf 100644 --- a/ovos_core/intent_services/manifest.py +++ b/ovos_core/intent_services/manifest.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import Optional + from ovos_bus_client.message import Message from ovos_spec_tools import standardize_lang from ovos_utils.log import LOG @@ -183,3 +185,54 @@ def _on_describe(self, message: Message): self.bus.emit(message.reply("ovos.intent.describe.response", {"ok": False, "error": f"unknown intent {skill_id}:{intent_name}:{lang}"})) + + # ------------------------------------------------------------------ + # orchestrator lookups — the passive index is the single source of an + # intent's declared context gates and slot names (OVOS-CONTEXT-1); + # the orchestrator reads them from here, never off the Match. + # ------------------------------------------------------------------ + + def _matching_definitions(self, session_id: str, skill_id: str, + intent_name: str, lang: Optional[str]) -> list: + lang = standardize_lang(lang) if lang else None + out = [] + for entry in self._effective_pool(session_id): + if entry["skill_id"] != skill_id or entry["intent_name"] != intent_name: + continue + if lang and entry["lang"] != lang: + continue + out.append(entry.get("definition") or {}) + return out + + def get_context_requirements(self, session_id: str, skill_id: str, + intent_name: str, lang: Optional[str] = None): + """OVOS-CONTEXT-1 §6/§6.1 — the ``requires_context`` / ``excludes_context`` + declared for an intent, unioned across its registration definitions. + + @return: a ``(requires, excludes)`` tuple of declaration lists (each a + bare-key string or a ``{key, scope}`` mapping); empty when the intent + declares no gates or is unknown. + """ + requires, excludes = [], [] + for d in self._matching_definitions(session_id, skill_id, intent_name, lang): + for r in (d.get("requires_context") or []): + if r not in requires: + requires.append(r) + for e in (d.get("excludes_context") or []): + if e not in excludes: + excludes.append(e) + return requires, excludes + + def get_slot_names(self, session_id: str, skill_id: str, + intent_name: str, lang: Optional[str] = None) -> list: + """The intent's declared slot / keyword names, unioned across its + registration definitions — the keyword model's ``required``/``optional``/ + ``one_of`` names (OVOS-INTENT-4 §5) and any template ``slots``. Used by + the §7 context-supplied slot rule.""" + names = [] + for d in self._matching_definitions(session_id, skill_id, intent_name, lang): + for field in ("required", "optional", "one_of", "slots"): + for name in (d.get(field) or []): + if name not in names: + names.append(name) + return names diff --git a/ovos_core/intent_services/service.py b/ovos_core/intent_services/service.py index d98dff63a6e..b528aae449a 100644 --- a/ovos_core/intent_services/service.py +++ b/ovos_core/intent_services/service.py @@ -38,6 +38,7 @@ from ovos_plugin_manager.templates.pipeline import IntentHandlerMatch, ConfidenceMatcherPipeline from ovos_core.intent_services.intent_context import ( + gate_satisfied, context_supplied_slots, prune as prune_intent_context, decrement as decrement_intent_context, @@ -570,6 +571,24 @@ def handle_utterance(self, message: Message): LOG.debug( f"ignoring match, intent '{match.match_type}' blacklisted by Session '{sess.session_id}'") continue + # OVOS-CONTEXT-1 §6/§6.1 — orchestrator gate backstop. A + # matcher SHOULD drop a candidate whose requires_context + # is unmet or whose excludes_context is present; core + # re-checks it so a misbehaving matcher cannot dispatch a + # context-gated intent. The declared gates are read from + # the passive INTENT-4 §10 manifest (the single source of + # an intent's declaration), never off the Match. Absent + # => ungated => unaffected. + if isinstance(match, IntentHandlerMatch) and match.skill_id: + intent_name = match.match_type.split(":", 1)[-1] + requires, excludes = self.intent_manifest.get_context_requirements( + sess.session_id, match.skill_id, intent_name, intent_lang) + if (requires or excludes) and not gate_satisfied( + sess.intent_context or {}, requires, excludes, + owner_id=match.skill_id): + LOG.debug( + f"ignoring match, context gate unsatisfied for '{match.match_type}'") + continue try: self._dispatch_match(match, message, intent_lang, pipeline_id=pipeline) @@ -633,6 +652,43 @@ def send_complete_intent_failure(self, message): # §9.5: universal end-marker self.bus.emit(message.reply(SpecMessage.UTTERANCE_HANDLED)) + def _apply_context_slots(self, match, sess, reply) -> None: + """OVOS-CONTEXT-1 §7 — apply the context-supplied slot rule to a + match before its dispatch is emitted. + + The rule needs the matched intent's ``requires_context`` list and + its slot / vocabulary names. Both are read from the passive INTENT-4 + §10 manifest — the single source of an intent's declaration — never + off the Match. An engine that implements §7 fills these slots itself; + this orchestrator-resident pass is the fallback. It is a no-op for an + intent that declares no context-gated slot, so it never disturbs + engines that already conform. + + @param match: the IntentHandlerMatch being dispatched. + @param sess: the session whose intent_context is consulted. + @param reply: the dispatch Message whose ``data`` slots are filled. + """ + if not (isinstance(match, IntentHandlerMatch) and match.skill_id): + return + intent_name = match.match_type.split(":", 1)[-1] + requires, _ = self.intent_manifest.get_context_requirements( + sess.session_id, match.skill_id, intent_name, sess.lang) + slot_names = self.intent_manifest.get_slot_names( + sess.session_id, match.skill_id, intent_name, sess.lang) + if not requires or not slot_names: + return + supplied = context_supplied_slots( + intent_context=sess.intent_context or {}, + requires=requires, + slot_names=slot_names, + owner_id=match.skill_id, + filled_slots=reply.data, + ) + for key, value in supplied.items(): + reply.data[key] = value + if supplied: + LOG.debug(f"context-supplied slots (§7): {supplied}") + def _apply_context_slots(self, match, sess, reply) -> None: """OVOS-CONTEXT-1 §7 — apply the context-supplied slot rule to a match before its dispatch is emitted. diff --git a/pyproject.toml b/pyproject.toml index 9cfa2639fec..49b80e17d7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,9 @@ dependencies = [ "watchdog>=2.1, <3.0", "combo-lock>=0.2.2, <0.4", "ovos-utils>=0.13.2a1,<1.0.0", + # OVOS-CONTEXT-1 §5.3: the SessionManager singleton owns the + # session.intent_context first-class field and the ovos.session.sync + # entry-by-entry merge (set + null-delete) — requires bus-client #239. "ovos_bus_client>=2.6.0a1,<3.0.0", "ovos-plugin-manager>=2.5.0a1,<3.0.0", "ovos-config>=0.0.13,<3.0.0", diff --git a/test/unittests/test_intent_context.py b/test/unittests/test_intent_context.py index 646466cc199..401421d6e9a 100644 --- a/test/unittests/test_intent_context.py +++ b/test/unittests/test_intent_context.py @@ -394,5 +394,80 @@ def test_midispatch_sync_survives_decay(self): self.assertEqual(ctx["new.skill:flag"]["turns_remaining"], 1) -if __name__ == "__main__": - unittest.main() +# --------------------------------------------------------------------------- +# §6/§6.1 — orchestrator gate backstop in the match loop +# --------------------------------------------------------------------------- + +from unittest.mock import patch # noqa: E402 +from ovos_plugin_manager.templates.pipeline import IntentHandlerMatch # noqa: E402 +from ovos_core.intent_services.manifest import IntentManifest # noqa: E402 + + +class TestOrchestratorGate(unittest.TestCase): + """handle_utterance drops a context-gated match whose gate — declared in + the passive §10 manifest, not on the Match — is unsatisfied, and dispatches + it once the required context is live.""" + + def _gated_service(self, match, requires=("kitchen",)): + svc = _make_service() + svc._handle_transformers = lambda m: m + svc.disambiguate_lang = lambda m: "en-US" + svc.send_complete_intent_failure = MagicMock() + svc._dispatch_match = MagicMock() + svc.get_pipeline = lambda session: [("fake-high", lambda utts, lang, msg: match)] + # declare the intent's requires_context in the passive manifest + svc.intent_manifest = IntentManifest(svc.bus) + if requires: + svc.intent_manifest._on_register(Message( + "ovos.intent.register.keyword", + {"skill_id": "lights.skill", "intent_name": "on", + "lang": "en-US", "requires_context": list(requires)}, {})) + return svc + + def _session(self, intent_context=None): + sess = Session("s1") + sess.lang = "en-US" + sess.pipeline = ["fake-high"] + sess.intent_context = intent_context + SessionManager.update(sess) + return sess + + def _match(self): + return IntentHandlerMatch(match_type="lights:on", + match_data={"conf": 1.0}, + skill_id="lights.skill", + utterance="turn on") + + def _utterance(self, sess): + return Message("ovos.utterance.handle", + {"utterances": ["turn on"], "lang": "en-US"}, + {"session": sess.serialize()}) + + def test_gate_unsatisfied_drops_match(self): + match = self._match() + svc = self._gated_service(match) + sess = self._session(intent_context=None) # no 'kitchen' context + with patch.object(svc, "_validate_session", return_value=sess): + svc.handle_utterance(self._utterance(sess)) + svc._dispatch_match.assert_not_called() + svc.send_complete_intent_failure.assert_called_once() + + def test_gate_satisfied_dispatches(self): + match = self._match() + svc = self._gated_service(match) + # private 'kitchen' under the declaring skill_id, live + ctx = {"lights.skill:kitchen": {"value": "kitchen", "turns_remaining": 2}} + sess = self._session(intent_context=ctx) + with patch.object(svc, "_validate_session", return_value=sess): + svc.handle_utterance(self._utterance(sess)) + svc._dispatch_match.assert_called_once() + svc.send_complete_intent_failure.assert_not_called() + + def test_ungated_match_unaffected(self): + match = IntentHandlerMatch(match_type="lights:on", match_data={"conf": 1.0}, + skill_id="lights.skill", utterance="turn on") + svc = self._gated_service(match, requires=None) # nothing declared + sess = self._session(intent_context=None) + with patch.object(svc, "_validate_session", return_value=sess): + svc.handle_utterance(self._utterance(sess)) + svc._dispatch_match.assert_called_once() diff --git a/test/unittests/test_intent_manifest.py b/test/unittests/test_intent_manifest.py index 97ac19d96e1..71f18674506 100644 --- a/test/unittests/test_intent_manifest.py +++ b/test/unittests/test_intent_manifest.py @@ -203,3 +203,56 @@ def test_describe_unknown_returns_error(self): def test_describe_missing_fields_returns_error(self): resp = self._query(skill_id="skill.a") self.assertFalse(resp["ok"]) + + +def _reg_ctx(skill_id, intent_name, requires=None, excludes=None, slots=None, + lang="en-US", method="keyword", session_id="default"): + data = {"skill_id": skill_id, "intent_name": intent_name, "lang": lang} + if requires is not None: + data["requires_context"] = requires + if excludes is not None: + data["excludes_context"] = excludes + if slots is not None: + data["required"] = slots + return Message(f"ovos.intent.register.{method}", data=data, + context={"session": {"session_id": session_id}, "skill_id": skill_id}) + + +class TestManifestContextLookups(unittest.TestCase): + def setUp(self): + self.m = _manifest() + + def test_context_requirements(self): + self.m._on_register(_reg_ctx("s.skill", "on", requires=["kitchen"], + excludes=["modal"])) + req, exc = self.m.get_context_requirements("default", "s.skill", "on", "en-US") + self.assertEqual(req, ["kitchen"]) + self.assertEqual(exc, ["modal"]) + + def test_context_requirements_empty_when_undeclared(self): + self.m._on_register(_reg_ctx("s.skill", "on")) + self.assertEqual(self.m.get_context_requirements("default", "s.skill", "on", "en-US"), + ([], [])) + + def test_context_requirements_unknown_intent(self): + self.assertEqual(self.m.get_context_requirements("default", "x", "y", "en-US"), + ([], [])) + + def test_context_requirements_union_across_methods(self): + self.m._on_register(_reg_ctx("s.skill", "on", requires=["a"], method="keyword")) + self.m._on_register(_reg_ctx("s.skill", "on", requires=["b"], method="template")) + req, _ = self.m.get_context_requirements("default", "s.skill", "on", "en-US") + self.assertEqual(sorted(req), ["a", "b"]) + + def test_slot_names(self): + self.m._on_register(_reg_ctx("s.skill", "on", slots=["room", "device"])) + self.assertEqual(self.m.get_slot_names("default", "s.skill", "on", "en-US"), + ["room", "device"]) + + def test_session_scoped_visible_via_effective_pool(self): + self.m._on_register(_reg_ctx("s.skill", "on", requires=["k"], session_id="sat-1")) + req, _ = self.m.get_context_requirements("sat-1", "s.skill", "on", "en-US") + self.assertEqual(req, ["k"]) + # a different session does not see the satellite-scoped declaration + self.assertEqual(self.m.get_context_requirements("other", "s.skill", "on", "en-US"), + ([], []))