Skip to content
Open
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
21 changes: 18 additions & 3 deletions docs/intent-service.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,29 @@ intent.service.intent.get {utterance: "...", lang: "..."}

| Event | Effect |
|---|---|
| `add_context` | Inject entity into session context |
| `remove_context` | Remove named context entity |
| `clear_context` | Clear all context entities |
| `add_context` | Inject entity into legacy frame-based session context |
| `remove_context` | Remove named context entity (legacy frames) |
| `clear_context` | Clear all context entities (legacy frames) |
| `ovos.session.sync` | OVOS-CONTEXT-1 §5.3 — handled by `SessionManager.handle_session_sync`, which merges `session.intent_context` entry-by-entry. `IntentService` does not subscribe to this event |

### OVOS-CONTEXT-1 intent context

The orchestrator implements the flat, decaying `session.intent_context`
key/value store defined by **OVOS-CONTEXT-1**. `SessionManager` owns the
map (carries it on every `Session`, applies the §5.3 `ovos.session.sync`
merge); `IntentService` applies the §4 decay lifecycle each match round
and provides the §6/§6.1 gating + §7 slot-fill as an orchestrator backstop
— matcher plugins are expected to apply these themselves via the shared
`ovos_spec_tools.context` helpers.

## Open Data / Metrics Upload

If `open_data.intent_urls` is configured, intent match results (utterance, intent type, lang, match data) are `POST`ed to each URL in a background thread. This is opt-in and has no default server.

## Bus Events Handled

`IntentService` itself subscribes to:

| Event | Handler |
|---|---|
| `recognizer_loop:utterance` | `handle_utterance` |
Expand All @@ -85,6 +98,8 @@ If `open_data.intent_urls` is configured, intent match results (utterance, inten
| `intent.service.skills.deactivate` | `_handle_deactivate` |
| `intent.service.pipelines.reload` | `handle_reload_pipelines` |

`ovos.session.sync` is handled by `SessionManager.handle_session_sync` (bus-client), not by `IntentService` — see [Context Management](#context-management) above.

---

## Cross-References
Expand Down
47 changes: 47 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 @@ -205,3 +207,48 @@ 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}"}))

# OVOS-CONTEXT-1: orchestrator lookups for declared context gates / slots

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 — declared ``requires_context`` /
``excludes_context``, unioned across registration definitions.

@return: ``(requires, excludes)`` tuple of declaration lists; 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 (``required``/
``optional``/``one_of``/``slots``), unioned across registration
definitions. 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
156 changes: 140 additions & 16 deletions ovos_core/intent_services/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@
from ovos_plugin_manager.pipeline import OVOSPipelineFactory
from ovos_plugin_manager.templates.pipeline import IntentHandlerMatch, ConfidenceMatcherPipeline

from ovos_spec_tools.context import (
gate_satisfied,
context_supplied_slots,
prune as prune_intent_context,
decrement as decrement_intent_context,
enforce_cap as enforce_intent_context_cap,
)


# Module-level constants for pipeline matcher migration and optimization
_PIPELINE_MIGRATION_MAP = {
Expand Down Expand Up @@ -164,6 +172,9 @@ def __init__(self, bus, config=None, preload_pipelines=True,

self.bus.on(SpecMessage.UTTERANCE, self.handle_utterance)

# OVOS-CONTEXT-1 §5.3: intent_context is owned by SessionManager,
# not subscribed to here.

# Context related handlers
self.bus.on('add_context', self.handle_add_context)
self.bus.on('remove_context', self.handle_remove_context)
Expand Down Expand Up @@ -367,35 +378,66 @@ def _missing_required_slots(self, match: IntentHandlerMatch,
match_data = match.match_data or {}
return [slot for slot in required_slots if not match_data.get(slot)]

@staticmethod
def _apply_post_match_decay(session_id: str, pre_match_entries: dict):
"""OVOS-CONTEXT-1 §4/§4.1: decrement turns_remaining on the managed
session, skipping keys refreshed since ``pre_match_entries`` was
snapshotted (compared by value, not identity, since reply/forward
round-trips entries through serialize/deserialize).

Must run before the dispatch reaches the IntentDispatcher / before
any §9.3/§9.5 terminal is emitted (see ``_dispatch_match``).
"""
sess = SessionManager.sessions.get(session_id) or SessionManager.get_default_session()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
post_ctx = dict(sess.intent_context or {})
unchanged_keys = {k for k in pre_match_entries
if k in post_ctx and post_ctx[k] == pre_match_entries[k]}
decrement_intent_context(post_ctx, only_keys=unchanged_keys)
sess.intent_context = post_ctx or None
SessionManager.update(sess)
return sess

def _dispatch_match(self, match: IntentHandlerMatch, message: Message, lang: str,
pipeline_id: str = None) -> None:
pipeline_id: str = None,
pre_match_entries: Optional[dict] = None) -> Optional[Message]:
"""Orchestrate the OVOS-PIPELINE-1 §6.1 post-match steps, then dispatch.

Runs the service-state-dependent post-match orchestration — the
intent-transformer chain (TRANSFORM-1 §3.4), skill activation +
``{skill_id}.activate``, session update, and ``context['pipeline_id']``
stamping (§7.1) — builds the dispatch Message, emits the §9.2
``ovos.intent.matched`` notification, and hands the dispatch Message to
the IntentDispatcher, which owns the §7 dispatch + §8 handler-lifecycle
trio.
Runs the intent-transformer chain, skill activation, session update,
the OVOS-CONTEXT-1 §4.2 decrement, and ``context['pipeline_id']``
stamping (§7.1); emits §9.2 ``ovos.intent.matched``; hands the
dispatch Message to the IntentDispatcher (§7/§8).

# OVOS-CONTEXT-1 §4.2: the decrement must run before the dispatch is
# put on the bus — a skill's ``SessionManager.get(message)`` fold
# would otherwise re-stamp the pre-decrement snapshot onto the
# registry, and every later terminal would carry the stale map.

Args:
match (IntentHandlerMatch): The matched intent (utterance, match_type,
skill_id, match_data, optional updated_session).
message (Message): The originating utterance Message to derive from.
lang (str): The content language of the match.
pipeline_id (str): The pipeline plugin that produced the match (§3.1).
pre_match_entries (Optional[dict]): §4.1 pre-match key->entry-value
snapshot, used to tell a mid-round sync apart from an
untouched entry when deciding what to decrement.

Returns:
None
Optional[Message]: the dispatch Message, or ``None`` if nothing
was dispatched.
"""
try:
match = self.intent_plugins.transform(match)
except Exception:
LOG.exception("_dispatch_match failed")

reply = None
sess = match.updated_session or SessionManager.get(message)
# not SessionManager.get(message): that would fold back the stale
# pre-round snapshot and erase a mid-round sync (SESSION-1)
sid = (message.context.get("session") or {}).get("session_id")
sess = (match.updated_session
or (sid and SessionManager.sessions.get(sid))
or SessionManager.get(message))
sess.lang = lang # ensure it is updated

# Launch intent handler
Expand Down Expand Up @@ -435,6 +477,21 @@ def _dispatch_match(self, match: IntentHandlerMatch, message: Message, lang: str
# emit event for skills callback -> self.handle_activate
self.bus.emit(reply.forward(f"{match.skill_id}.activate"))

# OVOS-CONTEXT-1 §5.1: promote matcher-captured entries onto session
promoted = getattr(match, "intent_context", None)
if promoted:
merged = SessionManager.merge_intent_context(
dict(sess.intent_context or {}), promoted)
enforce_intent_context_cap(merged)
sess.intent_context = merged or None
SessionManager.update(sess)

# OVOS-CONTEXT-1 §7: fill unfilled slots from live context
self._apply_context_slots(match, sess, reply)

# OVOS-CONTEXT-1 §4.2: decrement before dispatch (see docstring)
sess = self._apply_post_match_decay(sess.session_id, pre_match_entries or {})

# update Session if modified by pipeline
reply.context["session"] = sess.serialize()

Expand All @@ -456,13 +513,15 @@ def _dispatch_match(self, match: IntentHandlerMatch, message: Message, lang: str

intent_name = reply.msg_type.split(":", 1)[-1]
self.intent_dispatcher.dispatch(reply, skill_id, intent_name)
return reply

else: # upload intent metrics if enabled
if self.config.get("open_data", {}).get("intent_urls"):
create_daemon(self._upload_match_data, (match.utterance,
"complete_intent_failure",
lang,
match.match_data))
return None

@staticmethod
def _upload_match_data(utterance: str, intent: str, lang: str, match_data: dict):
Expand Down Expand Up @@ -567,10 +626,23 @@ def handle_utterance(self, message: Message):

# get session
sess = self._validate_session(message, lang)

# OVOS-CONTEXT-1 §4 (pre-match): prune dead entries so every matcher
# this round sees the same gating snapshot
intent_ctx = dict(sess.intent_context or {})
prune_intent_context(intent_ctx)
# §4.1: snapshot entry *value* (not identity, which reply/forward
# round-tripping churns) so a mid-dispatch refresh is exempted below
pre_match_entries = dict(intent_ctx)
sess.intent_context = intent_ctx or None
SessionManager.update(sess)
message.context["session"] = sess.serialize()

# match
match = None
# no_match_lang defers the §9.3/§9.5 emission until after §4.2 decay
dispatched_msg = None
no_match_lang = None
with stopwatch:
self._deactivations[sess.session_id] = []
# Loop through the matching functions until a match is found.
Expand Down Expand Up @@ -611,9 +683,23 @@ def handle_utterance(self, message: Message):
LOG.debug(f"ignoring match '{match.match_type}': "
f"missing required slots {missing} (§6.2)")
continue
# OVOS-CONTEXT-1 §6/§6.1: orchestrator gate backstop
# against a misbehaving matcher; gates read from the
# manifest, not the Match
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)
dispatched_msg = self._dispatch_match(
match, message, intent_lang, pipeline_id=pipeline,
pre_match_entries=pre_match_entries)
break
except Exception:
LOG.exception(f"{match_func} returned an invalid match")
Expand All @@ -622,13 +708,19 @@ def handle_utterance(self, message: Message):
continue
break
else:
# Nothing was able to handle the intent
# Ask politely for forgiveness for failing in this vital task
message.data["lang"] = lang
self.send_complete_intent_failure(message)
# Nothing was able to handle the intent. Defer §9.3/§9.5 until
# after the §4.2 decrement so the end-marker carries it.
no_match_lang = lang

LOG.debug(f"intent matching took: {stopwatch.time}")

# OVOS-CONTEXT-1 §4.2 no-match path (matched path decrements in
# _dispatch_match)
if no_match_lang is not None:
self._apply_post_match_decay(sess.session_id, pre_match_entries)
message.data["lang"] = no_match_lang
self.send_complete_intent_failure(message)

# sync any changes made to the default session, eg by ConverseService
if sess.session_id == "default":
SessionManager.sync(message)
Expand Down Expand Up @@ -660,6 +752,38 @@ 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 — fill an intent's unfilled slots from live
context. Fallback for engines that don't implement §7 themselves;
no-op when the intent declares no context-gated slot.

@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
# "filled" is judged against match.match_data, not reply.data, which
# carries framework/echo fields that could collide with a slot name
supplied = context_supplied_slots(
intent_context=sess.intent_context or {},
requires=requires,
slot_names=slot_names,
owner_id=match.skill_id,
filled_slots=match.match_data or {},
)
for key, value in supplied.items():
reply.data[key] = value
if supplied:
LOG.debug(f"context-supplied slots (§7): {supplied}")

@staticmethod
def handle_add_context(message: Message):
"""Add context
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ dependencies = [
"python-dateutil>=2.6, <3.0",
"combo-lock>=0.2.2, <0.4",
"ovos-utils>=0.13.5a1,<1.0.0",
# OVOS-CONTEXT-1 §5.3: SessionManager intent_context field (bus-client #239)
"ovos_bus_client>=2.6.0a1,<3.0.0",
"ovos-plugin-manager>=2.10.1a1,<3.0.0",
"ovos-config>=0.0.13,<3.0.0",
"ovos-workshop>=9.0.2a1,<10.0.0",
"rapidfuzz>=3.6,<4.0",
# OVOS-CONTEXT-1 §4/§4.1/§2: decay+cap primitives (ovos-spec-tools #77)
"ovos-spec-tools[langcodes]>=1.5.0a1,<2.0.0",
]

Expand Down
2 changes: 2 additions & 0 deletions test/unittests/test_dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@

from ovos_core.intent_services.service import IntentService
from ovos_core.intent_services.dispatcher import IntentDispatcher
from ovos_core.intent_services.manifest import IntentManifest

START = SpecMessage.INTENT_HANDLER_START.value
COMPLETE = SpecMessage.INTENT_HANDLER_COMPLETE.value
Expand Down Expand Up @@ -245,6 +246,7 @@ def _make_service(self):
it = MagicMock(); it.transform.side_effect = lambda i: i
svc.intent_plugins = it
svc.status = MagicMock()
svc.intent_manifest = IntentManifest(bus)
# mirror IntentService.__init__: the dispatcher notifies the orchestrator on
# each §8 terminal, which emits the §9.5 end-marker
svc.intent_dispatcher = IntentDispatcher(
Expand Down
Loading
Loading