11"""Intent service wrapping padacioso."""
22
3+ import re
34from functools import lru_cache
45from os .path import isfile
56from typing import Optional , Dict , List , Union
910from ovos_bus_client .session import SessionManager , Session
1011from ovos_config .config import Configuration
1112from 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 )
1315from ovos_utils import flatten_list
1416from ovos_utils .fakebus import FakeBus
1517from 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 :
0 commit comments