Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions ovos_core/intent_services/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
56 changes: 56 additions & 0 deletions ovos_core/intent_services/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
79 changes: 77 additions & 2 deletions test/unittests/test_intent_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
53 changes: 53 additions & 0 deletions test/unittests/test_intent_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
([], []))
Loading