Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
38 changes: 35 additions & 3 deletions docs/intent-service.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,46 @@ 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 **OVOS-CONTEXT-1** flat, decaying
`session.intent_context` key/value store. The core-resident helpers
(`ovos_core.intent_services.intent_context`) provide:

- the entry shape and *liveness* predicate (§2);
- the prune-then-decrement decay lifecycle, once per utterance dispatch
(§4 / §4.1);
- the §3.1 scope-resolution helper, the §6 / §6.1 gating predicates, and
the §7 context-supplied slot fill, as pure functions any in-process
engine can apply.

The authoritative owner of the map is the `SessionManager` singleton: it
carries `intent_context` as a first-class, round-tripping field on every
`Session` and applies the §5.3 `ovos.session.sync` entry-by-entry merge
itself — present entry objects set/replace, `null` entries delete, absent
keys unchanged. Around each match round the orchestrator applies the §4
decay lifecycle to the session's own map.

> **Engine-side gating.** The §6 / §6.1 `requires_context` /
> `excludes_context` enforcement *inside a matcher* (e.g. Adapt dropping a
> candidate whose required context is unsatisfied) lives in each pipeline
> plugin. Core provides the shared `gate_satisfied` /
> `context_supplied_slots` vocabulary those plugins consult, and re-checks
> the gate as an orchestrator backstop against a misbehaving matcher.

## 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 +115,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
244 changes: 244 additions & 0 deletions ovos_core/intent_services/intent_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
# Copyright 2024 OpenVoiceOS
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Core-resident helpers for OVOS-CONTEXT-1 — intent context.

OVOS-CONTEXT-1 models intent context as a **flat, decaying key/value
map** stored at ``session.intent_context`` (§2).

The authoritative owner of that map is the ``SessionManager`` singleton
(``ovos_bus_client.session.SessionManager``): it carries
``intent_context`` as a first-class, round-tripping field on every
``Session`` and applies the §5.3 ``ovos.session.sync`` entry-by-entry
merge (set + null-delete) itself — see ``SessionManager.handle_session_sync``
/ ``SessionManager.merge_intent_context``. The orchestrator does **not**
subscribe to ``ovos.session.sync`` and does **not** hold a parallel store.

This module is therefore a set of **stateless helpers** the orchestrator
(and any in-process engine) applies to a session's ``intent_context``
map. It owns no state; every function takes the map (or an entry) as an
argument and returns a value or mutates the passed-in dict in place:

- the entry-shape *liveness* predicate (§2);
- the prune-then-decrement decay lifecycle (§4 / §4.1);
- the §2 live-entry cap eviction;
- the §3.1 scope-resolution helper that maps a gating declaration to a
stored key, the §6 / §6.1 gating predicates, and the §7
context-supplied slot fill.

Engine-side enforcement of §6 / §6.1 gating inside a matcher (e.g. a
matcher dropping a candidate whose ``requires_context`` is unsatisfied)
belongs to each pipeline plugin, not to this module. What lives here is
the shared, engine-agnostic vocabulary those plugins (and the
orchestrator) consult.
"""
import time
from typing import Any, Dict, List, Optional, Union

from ovos_utils.log import LOG

#: OVOS-CONTEXT-1 §2 — the JSON field, inside the session carrier, that
#: holds the flat intent-context map. First-class on ``Session`` in
#: bus-client (round-trips through serialize/deserialize).
INTENT_CONTEXT_FIELD = "intent_context"

#: OVOS-CONTEXT-1 §2 / OVOS-MSG-1 §2.1.1 — the single load-bearing
#: separator between a private entry's owner and its sub-key. A prefixed
#: (private) key contains exactly one ``:``; a bare (shared) key none.
SCOPE_SEPARATOR = ":"

def is_live(entry: Dict[str, Any], now: Optional[float] = None) -> bool:
"""OVOS-CONTEXT-1 §2 liveness predicate.

An entry is **live** iff both of:

- ``turns_remaining`` is unset, ``null``, or strictly greater than 0;
- ``expires_at`` is unset, ``null``, or strictly greater than the
current Unix time.

@param entry: a context entry object (``value`` plus optional
``expires_at`` / ``turns_remaining``).
@param now: current Unix time; defaults to ``time.time()``.
@return: True if the entry is live.
"""
if not isinstance(entry, dict):
return False
now = time.time() if now is None else now

turns = entry.get("turns_remaining")
if turns is not None and not turns > 0:
return False

expires = entry.get("expires_at")
if expires is not None and not expires > now:
return False

return True


def resolve_key(key: str, scope: str, owner_id: Optional[str]) -> Optional[str]:
"""OVOS-CONTEXT-1 §3.1 — map a gating declaration to a stored key.

- ``scope == "private"`` resolves to ``<owner_id>:<key>``; shared
entries with the same key do **not** satisfy a private gate.
- ``scope == "shared"`` resolves to the bare ``<key>``; private
entries with the same name do **not** satisfy a shared gate.

@param key: the caller-chosen sub-key (unprefixed).
@param scope: ``"private"`` or ``"shared"``.
@param owner_id: the declaring intent's ``skill_id`` / ``pipeline_id``;
required for private scope.
@return: the stored key, or None if a private lookup has no owner.
"""
if scope == "shared":
return key
# private (the safe default)
if not owner_id:
return None
return f"{owner_id}{SCOPE_SEPARATOR}{key}"


def normalize_declaration(entry: Union[str, Dict[str, Any]]) -> Optional[Dict[str, str]]:
"""OVOS-CONTEXT-1 §6 — normalize one ``requires_context`` /
``excludes_context`` entry to ``{key, scope}``.

A bare string is interpreted as ``{key: <string>, scope: "private"}``
— the safe default (§6). A mapping may set an explicit ``scope``.

@param entry: a bare key string or a ``{key, scope}`` mapping.
@return: a normalized ``{key, scope}`` dict, or None if malformed.
"""
if isinstance(entry, str):
return {"key": entry, "scope": "private"}
if isinstance(entry, dict) and entry.get("key"):
scope = entry.get("scope", "private")
if scope not in ("private", "shared"):
LOG.warning(f"invalid context scope '{scope}', defaulting to private")
scope = "private"
return {"key": entry["key"], "scope": scope}
LOG.warning(f"malformed context declaration: {entry!r}")
return None


def gate_satisfied(intent_context: Dict[str, Any],
requires: Optional[List[Union[str, Dict]]],
excludes: Optional[List[Union[str, Dict]]],
owner_id: Optional[str],
now: Optional[float] = None) -> bool:
"""OVOS-CONTEXT-1 §6 / §6.1 — evaluate the positive and negative
gating contracts against a (post-decay, §4) context snapshot.

A match is permitted iff **every** ``requires_context`` key resolves
to a live entry **and** **no** ``excludes_context`` key resolves to a
live entry, each resolved per §3.1.

@param intent_context: the flat ``session.intent_context`` map.
@param requires: ``requires_context`` declarations, or None/empty.
@param excludes: ``excludes_context`` declarations, or None/empty.
@param owner_id: the declaring intent's ``skill_id`` / ``pipeline_id``.
@param now: current Unix time; defaults to ``time.time()``.
@return: True if the gate permits the match.
"""
intent_context = intent_context or {}
now = time.time() if now is None else now

for decl in (requires or []):
norm = normalize_declaration(decl)
if norm is None:
return False # malformed declaration can never be satisfied
stored = resolve_key(norm["key"], norm["scope"], owner_id)
entry = intent_context.get(stored) if stored else None
if entry is None or not is_live(entry, now):
return False

for decl in (excludes or []):
norm = normalize_declaration(decl)
if norm is None:
continue
stored = resolve_key(norm["key"], norm["scope"], owner_id)
entry = intent_context.get(stored) if stored else None
if entry is not None and is_live(entry, now):
return False

return True


def context_supplied_slots(intent_context: Dict[str, Any],
requires: Optional[List[Union[str, Dict]]],
slot_names: List[str],
owner_id: Optional[str],
filled_slots: Optional[Dict[str, Any]] = None,
now: Optional[float] = None) -> Dict[str, Any]:
"""OVOS-CONTEXT-1 §7 — the context-supplied slot rule.

When a ``requires_context`` key ``k`` **also names a slot** of the
intent definition, and the §3.1-selected entry's ``value`` is
non-null, and the utterance did **not** itself fill slot ``k``,
populate ``Match.slots[k]`` from that value (keyed by ``k``,
unprefixed). Utterance-produced values always win — context is a
fallback, not an override.

@param intent_context: the flat ``session.intent_context`` map.
@param requires: the intent's ``requires_context`` declarations.
@param slot_names: the slot / vocabulary names of the intent
definition.
@param owner_id: the declaring intent's ``skill_id`` / ``pipeline_id``.
@param filled_slots: slots the utterance itself produced (these win).
@param now: current Unix time; defaults to ``time.time()``.
@return: a mapping of slot-name -> context-supplied value (only the
slots this rule fills; empty if none apply).
"""
intent_context = intent_context or {}
filled_slots = filled_slots or {}
slot_names = set(slot_names or [])
now = time.time() if now is None else now
supplied: Dict[str, Any] = {}

for decl in (requires or []):
norm = normalize_declaration(decl)
if norm is None:
continue
key = norm["key"]
if key not in slot_names:
continue # gated only, §7 does not apply
if filled_slots.get(key) not in (None, ""):
continue # utterance-produced value wins
stored = resolve_key(key, norm["scope"], owner_id)
entry = intent_context.get(stored) if stored else None
if entry is None or not is_live(entry, now):
continue
value = entry.get("value")
if value is None:
continue # flag-context has no value to supply
supplied[key] = value

return supplied



# ---------------------------------------------------------------------------
# §4 decay lifecycle (prune / decrement) and §2 cap eviction moved to the
# session layer (ovos_spec_tools.context, alongside the already-merged
# SessionManager.merge_intent_context — see OVOS-CONTEXT-1 §5.3): they are
# map-mutation mechanics, not orchestrator decisions, so they belong next to
# the singleton that owns ``session.intent_context``. The orchestrator keeps
# deciding WHEN a turn happened and which entries are exempt from decay
# (``IntentService._apply_post_match_decay``, §4.1's same-dispatch refresh
# exemption compared by value) — only the mechanics re-export here for
# backward-compatible imports.
from ovos_spec_tools.context import ( # noqa: E402,F401
prune,
decrement,
enforce_cap,
DEFAULT_MAX_ENTRIES,
)
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 @@ -205,3 +207,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
Loading
Loading