Skip to content

Commit 6389814

Browse files
JarbasAlclaude
andcommitted
feat: OVOS-CONTEXT-1 §7 uniform slot fill + INTENT-2 §4.3 slot blacklist
Fill every declared template slot of a matched intent from a live session context entry (private <skill_id>:name over shared bare name) when the utterance left it unresolved, independent of requires_context. A slot the utterance binds to a blacklisted value (INTENT-2 §4.3, whole-word-sequence) is treated as unresolved so context supplies it. Utterance-produced values always win; requires/excludes flag gating (gate_satisfied) still governs only the flags. Build a per-intent slot-name index at registration by parsing {slot} markers from the template samples, so both the spec-template and legacy paths are covered. Pin ovos-spec-tools>=1.4.0a2 for context_slot_candidates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e936eed commit 6389814

4 files changed

Lines changed: 186 additions & 4 deletions

File tree

padacioso/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ def fuzzy_match(x, against):
7373
class IntentContainer:
7474
def __init__(self, fuzz=False, n_workers=4):
7575
self.intent_samples, self.entity_samples = {}, {}
76+
# OVOS-CONTEXT-1 §7 — per-intent set of declared template slot names,
77+
# parsed from the ``{slot}`` markers of the samples at registration.
78+
# Consumed by the pipeline to offer live context entries as slot
79+
# candidates for slots the utterance itself left unresolved.
80+
self.intent_slots = {}
7681
# self.intents, self.entities = {}, {}
7782
self.fuzz = fuzz
7883
self.workers = n_workers
@@ -105,6 +110,19 @@ def _get_fuzzed(sample: str) -> List[str]:
105110
fuzzed.append(" ".join(new_words))
106111
return fuzzed + [f"* {sample}", f"{sample} *"]
107112

113+
@staticmethod
114+
def _slot_names(patterns: List[str]) -> set:
115+
"""Return the set of ``{slot}`` names declared across the patterns.
116+
117+
simplematch markers are ``{name}`` or ``{name:type}``; only the name is
118+
kept. Names are lower-cased to line up with the lower-cased match keys.
119+
"""
120+
names = set()
121+
for p in patterns:
122+
for m in re.finditer(r"\{\s*(\w+)", p):
123+
names.add(m.group(1).lower())
124+
return names
125+
108126
@staticmethod
109127
def _literal_words(pattern: str) -> frozenset:
110128
"""Return the set of non-entity, non-wildcard words in a pattern."""
@@ -130,6 +148,7 @@ def add_intent(self, name: str, lines: List[str]):
130148
# short-circuit before greedy entity patterns consume the query
131149
regexes.sort(key=lambda r: (0 if "{" not in r and "*" not in r else 1, -len(r)))
132150
self.intent_samples[name] = regexes
151+
self.intent_slots[name] = self._slot_names(regexes)
133152
for r in regexes:
134153
cm = simplematch.Matcher(r, case_sensitive=True)
135154
um = simplematch.Matcher(r, case_sensitive=False)
@@ -153,6 +172,7 @@ def remove_intent(self, name: str):
153172
"""
154173
if name in self.intent_samples:
155174
regexes = self.intent_samples.pop(name)
175+
self.intent_slots.pop(name, None)
156176
for rx in regexes:
157177
if rx in self._cased_matchers:
158178
self._cased_matchers.pop(rx)

padacioso/opm.py

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Intent service wrapping padacioso."""
22

3+
import re
34
from functools import lru_cache
45
from os.path import isfile
56
from typing import Optional, Dict, List, Union
@@ -9,7 +10,8 @@
910
from ovos_bus_client.session import SessionManager, Session
1011
from ovos_config.config import Configuration
1112
from ovos_plugin_manager.templates.pipeline import ConfidenceMatcherPipeline, IntentHandlerMatch
12-
from ovos_spec_tools import closest_lang, standardize_lang, SpecMessage, gate_satisfied
13+
from ovos_spec_tools import (closest_lang, standardize_lang, SpecMessage,
14+
gate_satisfied, context_slot_candidates)
1315
from ovos_utils import flatten_list
1416
from ovos_utils.fakebus import FakeBus
1517
from ovos_utils.log import LOG, log_deprecation
@@ -104,6 +106,11 @@ def __init__(self, bus: Optional[Union[MessageBusClient, FakeBus]] = None,
104106
# lang and of the enable/disable/detach match state: retained until the
105107
# intent is deregistered/detached so a re-armed intent keeps its gate.
106108
self._intent_context_gates = {}
109+
# OVOS-INTENT-2 §4.3 — per-slot value blacklists, keyed by internal
110+
# intent name -> {slot_name: [blacklisted values]}. A slot bound by the
111+
# utterance to a blacklisted value is treated as unresolved so the
112+
# OVOS-CONTEXT-1 §7 slot fill can supply it from session context.
113+
self._intent_slot_blacklists = {}
107114
self.max_words = 50 # if an utterance contains more words than this, don't attempt to match
108115
LOG.debug('Loaded Padacioso intent parser.')
109116

@@ -121,6 +128,24 @@ def _store_context_gate(self, name: str, data: Dict):
121128
else:
122129
self._intent_context_gates.pop(name, None)
123130

131+
def _store_slot_blacklist(self, name: str, data: Dict):
132+
"""OVOS-INTENT-2 §4.3 — retain per-slot value blacklists for an intent.
133+
134+
The registration payload may carry a ``slot_blacklist`` mapping (or a
135+
dict-typed ``blacklist``) keyed by slot name -> list of values that must
136+
never bind that slot. Absent/empty declarations clear any prior entry.
137+
"""
138+
blacklist = data.get("slot_blacklist")
139+
if blacklist is None and isinstance(data.get("blacklist"), dict):
140+
# the intent-level ``blacklist`` (§6.1 suppression phrases) is a
141+
# list; a dict here is the per-slot exclusion contract instead.
142+
blacklist = data.get("blacklist")
143+
if blacklist:
144+
self._intent_slot_blacklists[name] = {
145+
slot.lower(): list(values) for slot, values in blacklist.items()}
146+
else:
147+
self._intent_slot_blacklists.pop(name, None)
148+
124149
@staticmethod
125150
def _internal_name(skill_id: str, intent_name: str) -> str:
126151
"""Compose the engine-internal namespaced intent name.
@@ -201,6 +226,7 @@ def __detach_intent(self, intent_name):
201226
if intent_name in self.registered_intents:
202227
self.registered_intents.remove(intent_name)
203228
self._intent_context_gates.pop(intent_name, None)
229+
self._intent_slot_blacklists.pop(intent_name, None)
204230
for lang in self.containers:
205231
self.containers[lang].remove_intent(intent_name)
206232
# the container was mutated; drop stale cached matches
@@ -278,6 +304,7 @@ def register_intent(self, message):
278304
if lang in self.containers:
279305
self.registered_intents.append(message.data['name'])
280306
self._store_context_gate(message.data['name'], message.data)
307+
self._store_slot_blacklist(message.data['name'], message.data)
281308
try:
282309
self._register_object(message, 'intent',
283310
self.containers[lang].add_intent)
@@ -344,6 +371,7 @@ def handle_register_template(self, message: Message):
344371
self.registered_intents.append(name)
345372
self._template_samples[(lang, name)] = list(samples)
346373
self._store_context_gate(name, data)
374+
self._store_slot_blacklist(name, data)
347375
try:
348376
self.containers[lang].add_intent(name, samples)
349377
except RuntimeError:
@@ -504,11 +532,11 @@ def calc_intent(self, utterances: List[str], lang: str = None,
504532
blacklisted_intents, blacklisted_skills)
505533
for utt in utterances]
506534
intents = [i for i in intents if i is not None]
535+
ctx = sess.intent_context or {}
507536
# OVOS-CONTEXT-1 §6/§6.1 — drop candidates whose requires_context /
508537
# excludes_context gate is not satisfied by the live session context.
509538
# gate_satisfied handles §2 liveness, §3.1 scope and §4 decay.
510539
if self._intent_context_gates:
511-
ctx = sess.intent_context or {}
512540
gated = []
513541
for i in intents:
514542
gate = self._intent_context_gates.get(i.name)
@@ -524,7 +552,53 @@ def calc_intent(self, utterances: List[str], lang: str = None,
524552
intents = gated
525553
# select best
526554
if intents:
527-
return max(intents, key=lambda k: k.conf)
555+
best = max(intents, key=lambda k: k.conf)
556+
self._apply_slot_context(best, ctx, lang)
557+
return best
558+
559+
def _apply_slot_context(self, intent: PadaciosoIntent, ctx: Dict, lang: str):
560+
"""OVOS-CONTEXT-1 §7 uniform slot fill + OVOS-INTENT-2 §4.3 blacklist.
561+
562+
For the matched intent, drop any slot the utterance bound to a
563+
blacklisted value (§4.3, whole-word-sequence), then, for every declared
564+
template slot still unresolved, offer a live non-null session context
565+
entry as its value. Private ``<skill_id>:name`` entries take precedence
566+
over shared bare ``name`` (handled by ``context_slot_candidates``). A
567+
value the utterance itself produced always wins and is never overwritten.
568+
This is independent of ``requires_context``; the gate above governs only
569+
the flag-style requires/excludes declarations.
570+
"""
571+
slot_names = self.containers[lang].intent_slots.get(intent.name)
572+
if not slot_names:
573+
return
574+
# §4.3 — un-bind slots the utterance filled with a blacklisted value
575+
for slot, values in self._intent_slot_blacklists.get(intent.name, {}).items():
576+
bound = intent.matches.get(slot)
577+
if bound is not None and self._value_blacklisted(str(bound), values):
578+
intent.matches.pop(slot, None)
579+
# §7 — fill unresolved declared slots from live context
580+
unresolved = [s for s in slot_names if not intent.matches.get(s)]
581+
if not unresolved:
582+
return
583+
owner_id = intent.name.split(":")[0]
584+
for slot, value in context_slot_candidates(
585+
ctx or {}, unresolved, owner_id=owner_id).items():
586+
if not intent.matches.get(slot): # utterance value always wins
587+
intent.matches[slot] = value
588+
589+
@staticmethod
590+
def _value_blacklisted(value: str, blacklist: List[str]) -> bool:
591+
"""Whole-word-sequence membership of a bound value in a blacklist."""
592+
v = value.lower()
593+
v_words = v.split()
594+
for bad in blacklist:
595+
bad = str(bad).lower()
596+
if ' ' not in bad:
597+
if bad in v_words:
598+
return True
599+
elif re.search(r'\b' + re.escape(bad) + r'\b', v):
600+
return True
601+
return False
528602

529603
def _get_closest_lang(self, lang: str) -> Optional[str]:
530604
if self.containers:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ authors = [{ name = "jarbasai", email = "jarbasai@mailfence.com" }]
1212
requires-python = ">=3.8"
1313
dependencies = [
1414
"simplematch",
15-
"ovos-spec-tools>=1.4.0a1",
15+
"ovos-spec-tools>=1.4.0a2",
1616
"ovos-utils>=0.3.5,<1.0.0",
1717
]
1818

test/test_pipeline.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,5 +315,93 @@ def test_gate_dropped_on_deregister(self):
315315
self.assertNotIn("tv.skill:off", svc._intent_context_gates)
316316

317317

318+
class ContextSlotFillTest(unittest.TestCase):
319+
"""OVOS-CONTEXT-1 §7 uniform slot fill + OVOS-INTENT-2 §4.3 blacklist."""
320+
321+
def get_service(self):
322+
from ovos_spec_tools import SpecMessage
323+
self.SpecMessage = SpecMessage
324+
return PadaciosoPipeline(FakeBus(), {"fuzz": False})
325+
326+
def _msg_with_context(self, intent_context):
327+
from ovos_bus_client.session import Session
328+
sess = Session("test-session")
329+
sess.intent_context = intent_context
330+
return Message("recognizer_loop:utterance", {},
331+
{"session": sess.serialize()})
332+
333+
def _register(self, svc, **data):
334+
data.setdefault("lang", "en-US")
335+
svc.handle_register_template(Message(
336+
self.SpecMessage.INTENT_REGISTER_TEMPLATE.value, data))
337+
338+
def test_slot_filled_from_context_without_requires(self):
339+
# (a) a declared slot fills from live context with NO requires_context.
340+
# The utterance matches the slot-free variant, so {location} — declared
341+
# by the sibling sample — is left unresolved and taken from context.
342+
svc = self.get_service()
343+
self._register(svc, skill_id="weather.skill", intent_name="forecast",
344+
samples=["whats the weather",
345+
"whats the weather in {location}"])
346+
self.assertNotIn("weather.skill:forecast", svc._intent_context_gates)
347+
msg = self._msg_with_context(
348+
{"weather.skill:location": {"value": "Lisbon"}})
349+
intent = svc.calc_intent("whats the weather", "en-US", msg)
350+
self.assertEqual(intent.name, "weather.skill:forecast")
351+
self.assertEqual(intent.matches.get("location"), "Lisbon")
352+
353+
def test_utterance_value_wins_over_context(self):
354+
# (b) a value the utterance produces is never overwritten by context
355+
svc = self.get_service()
356+
self._register(svc, skill_id="weather.skill", intent_name="how_tall",
357+
samples=["how tall is {person}"])
358+
msg = self._msg_with_context(
359+
{"weather.skill:person": {"value": "Michael Jordan"}})
360+
intent = svc.calc_intent("how tall is shaquille oneal", "en-US", msg)
361+
self.assertEqual(intent.matches.get("person"), "shaquille oneal")
362+
363+
def test_blacklisted_value_becomes_context(self):
364+
# (c) a blacklisted bound value ("he") -> unresolved -> context fills
365+
svc = self.get_service()
366+
self._register(svc, skill_id="weather.skill", intent_name="how_tall",
367+
samples=["how tall is {person}"],
368+
slot_blacklist={"person": ["he", "she", "it"]})
369+
self.assertIn("weather.skill:how_tall", svc._intent_slot_blacklists)
370+
msg = self._msg_with_context(
371+
{"weather.skill:person": {"value": "Michael Jordan"}})
372+
intent = svc.calc_intent("how tall is he", "en-US", msg)
373+
self.assertEqual(intent.matches.get("person"), "Michael Jordan")
374+
375+
def test_flag_key_still_only_gates(self):
376+
# (d) a requires_context flag key gates the match but is not a slot fill;
377+
# it never lands in the match data
378+
svc = self.get_service()
379+
self._register(svc, skill_id="tv.skill", intent_name="turn_off",
380+
samples=["turn off the tv"],
381+
requires_context=["tv_on"])
382+
msg = self._msg_with_context({"tv.skill:tv_on": {"value": True}})
383+
intent = svc.calc_intent("turn off the tv", "en-US", msg)
384+
self.assertEqual(intent.name, "tv.skill:turn_off")
385+
self.assertNotIn("tv_on", intent.matches)
386+
387+
def test_shared_scope_and_private_precedence(self):
388+
svc = self.get_service()
389+
self._register(svc, skill_id="weather.skill", intent_name="forecast",
390+
samples=["whats the weather",
391+
"whats the weather in {location}"])
392+
# shared bare key used when no private entry exists
393+
msg = self._msg_with_context({"location": {"value": "shared city"}})
394+
self.assertEqual(
395+
svc.calc_intent("whats the weather", "en-US", msg
396+
).matches.get("location"), "shared city")
397+
# private entry wins over shared
398+
msg = self._msg_with_context(
399+
{"location": {"value": "shared city"},
400+
"weather.skill:location": {"value": "priv city"}})
401+
self.assertEqual(
402+
svc.calc_intent("whats the weather", "en-US", msg
403+
).matches.get("location"), "priv city")
404+
405+
318406
if __name__ == "__main__":
319407
unittest.main()

0 commit comments

Comments
 (0)