Skip to content

Commit b4449ae

Browse files
JarbasAlclaude
andauthored
refactor: re-export intent-definition primitives from ovos-spec-tools (#432)
* refactor: re-export intent-definition primitives from ovos-spec-tools Intent, IntentBuilder and open_intent_envelope are now re-exported from ovos-spec-tools (OVOS-INTENT-4 adapt-free keyword-intent definition model) instead of being defined locally as adapt metaclass wrappers. Skills keep their `from ovos_workshop.intents import IntentBuilder` import; the canonical implementation lives in the spec. IntentServiceInterface and the munge_* helpers are unchanged — they consume the re-exported definitions. The spec-tools IntentBuilder is API-compatible (.require/.optionally/.one_of/.exclude/.build/.name) and Intent keeps the same attribute surface (name/requires/at_least_one/optional/excludes). The local matching logic (Intent.validate / _resolve_one_of) is dropped: per OVOS-PIPELINE-1 matching is an engine-plugin concern, not part of the definition primitive. test_intent.py (which exercised that matching logic) is removed accordingly. pyproject: bump ovos-spec-tools floor to >=0.14.0a1 (the version carrying the intent primitives). CI pre-installs the unpublished spec-tools branch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: pin published ovos-spec-tools>=0.16.1a2, drop git-dep Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Update build_tests.yml --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b3c55d6 commit b4449ae

3 files changed

Lines changed: 8 additions & 588 deletions

File tree

ovos_workshop/intents.py

Lines changed: 7 additions & 316 deletions
Original file line numberDiff line numberDiff line change
@@ -1,314 +1,17 @@
1-
import abc
2-
import itertools
31
from os.path import exists
42
from threading import RLock
5-
from typing import List, Tuple, Optional
3+
from typing import List, Optional
64
import warnings
75
from ovos_bus_client.message import Message, dig_for_message
86
from ovos_bus_client.util import get_mycroft_bus
97
from ovos_utils.log import LOG, log_deprecation
108

11-
12-
class _IntentMeta(abc.ABCMeta):
13-
def __instancecheck__(self, instance):
14-
check = super().__instancecheck__(instance)
15-
if not check:
16-
try:
17-
# backwards compat isinstancechecks
18-
from adapt.intent import Intent as _I
19-
check = isinstance(instance, _I)
20-
except ImportError:
21-
pass
22-
return check
23-
24-
25-
class Intent(metaclass=_IntentMeta):
26-
def __init__(self, name="", requires=None, at_least_one=None, optional=None, excludes=None):
27-
"""Create Intent object
28-
29-
Args:
30-
name(str): Name for Intent
31-
requires(list): Entities that are required
32-
at_least_one(list): One of these Entities are required
33-
optional(list): Optional Entities used by the intent
34-
"""
35-
self.name = name
36-
self.requires = requires or []
37-
self.at_least_one = at_least_one or []
38-
self.optional = optional or []
39-
self.excludes = excludes or []
40-
41-
def validate(self, tags, confidence):
42-
"""Using this method removes tags from the result of validate_with_tags
43-
44-
Returns:
45-
intent(intent): Results from validate_with_tags
46-
"""
47-
intent, tags = self.validate_with_tags(tags, confidence)
48-
return intent
49-
50-
def validate_with_tags(self, tags, confidence):
51-
"""Validate whether tags has required entites for this intent to fire
52-
53-
Args:
54-
tags(list): Tags and Entities used for validation
55-
confidence(float): The weight associate to the parse result,
56-
as indicated by the parser. This is influenced by a parser
57-
that uses edit distance or context.
58-
59-
Returns:
60-
intent, tags: Returns intent and tags used by the intent on
61-
failure to meat required entities then returns intent with
62-
confidence
63-
of 0.0 and an empty list for tags.
64-
"""
65-
result = {'intent_type': self.name}
66-
intent_confidence = 0.0
67-
local_tags = tags[:]
68-
used_tags = []
69-
70-
# Check excludes first
71-
for exclude_type in self.excludes:
72-
exclude_tag, _canonical_form, _tag_confidence = \
73-
self._find_first_tag(local_tags, exclude_type)
74-
if exclude_tag:
75-
result['confidence'] = 0.0
76-
return result, []
77-
78-
for require_type, attribute_name in self.requires:
79-
required_tag, canonical_form, tag_confidence = \
80-
self._find_first_tag(local_tags, require_type)
81-
if not required_tag:
82-
result['confidence'] = 0.0
83-
return result, []
84-
85-
result[attribute_name] = canonical_form
86-
if required_tag in local_tags:
87-
local_tags.remove(required_tag)
88-
used_tags.append(required_tag)
89-
intent_confidence += tag_confidence
90-
91-
if len(self.at_least_one) > 0:
92-
best_resolution = self._resolve_one_of(local_tags, self.at_least_one)
93-
if not best_resolution:
94-
result['confidence'] = 0.0
95-
return result, []
96-
else:
97-
for key in best_resolution:
98-
# TODO: at least one should support aliases
99-
result[key] = best_resolution[key][0].get('key')
100-
intent_confidence += 1.0 * best_resolution[key][0]['entities'][0].get('confidence', 1.0)
101-
used_tags.append(best_resolution[key][0])
102-
if best_resolution in local_tags:
103-
local_tags.remove(best_resolution[key][0])
104-
105-
for optional_type, attribute_name in self.optional:
106-
optional_tag, canonical_form, tag_confidence = \
107-
self._find_first_tag(local_tags, optional_type)
108-
if not optional_tag or attribute_name in result:
109-
continue
110-
result[attribute_name] = canonical_form
111-
if optional_tag in local_tags:
112-
local_tags.remove(optional_tag)
113-
used_tags.append(optional_tag)
114-
intent_confidence += tag_confidence
115-
116-
total_confidence = (intent_confidence / len(tags) * confidence) \
117-
if tags else 0.0
118-
119-
CLIENT_ENTITY_NAME = 'Client' # TODO - ??? what is this magic string
120-
121-
target_client, canonical_form, confidence = \
122-
self._find_first_tag(local_tags, CLIENT_ENTITY_NAME)
123-
124-
result['target'] = target_client.get('key') if target_client else None
125-
result['confidence'] = total_confidence
126-
127-
return result, used_tags
128-
129-
@classmethod
130-
def _resolve_one_of(cls, tags, at_least_one):
131-
"""Search through all combinations of at_least_one rules to find a
132-
combination that is covered by tags
133-
134-
Args:
135-
tags(list): List of tags with Entities to search for Entities
136-
at_least_one(list): List of Entities to find in tags
137-
138-
Returns:
139-
object:
140-
returns None if no match is found but returns any match as an object
141-
"""
142-
for possible_resolution in itertools.product(*at_least_one):
143-
resolution = {}
144-
pr = possible_resolution[:]
145-
for entity_type in pr:
146-
last_end_index = -1
147-
if entity_type in resolution:
148-
last_end_index = resolution[entity_type][-1].get('end_token')
149-
tag, value, c = cls._find_first_tag(tags, entity_type,
150-
after_index=last_end_index)
151-
if not tag:
152-
break
153-
else:
154-
if entity_type not in resolution:
155-
resolution[entity_type] = []
156-
resolution[entity_type].append(tag)
157-
# Check if this is a valid resolution (all one_of rules matched)
158-
if len(resolution) == len(possible_resolution):
159-
return resolution
160-
161-
return None
162-
163-
@staticmethod
164-
def _find_first_tag(tags, entity_type, after_index=-1):
165-
"""Searches tags for entity type after given index
166-
167-
Args:
168-
tags(list): a list of tags with entity types to be compared to
169-
entity_type
170-
entity_type(str): This is he entity type to be looking for in tags
171-
after_index(int): the start token must be greater than this.
172-
173-
Returns:
174-
( tag, v, confidence ):
175-
tag(str): is the tag that matched
176-
v(str): ? the word that matched?
177-
confidence(float): is a measure of accuracy. 1 is full confidence
178-
and 0 is none.
179-
"""
180-
for tag in tags:
181-
for entity in tag.get('entities'):
182-
for v, t in entity.get('data'):
183-
if t.lower() == entity_type.lower() and \
184-
(tag.get('start_token', 0) > after_index or \
185-
tag.get('from_context', False)):
186-
return tag, v, entity.get('confidence')
187-
188-
return None, None, None
189-
190-
191-
class _IntentBuilderMeta(abc.ABCMeta):
192-
def __instancecheck__(self, instance):
193-
check = super().__instancecheck__(instance)
194-
if not check:
195-
try:
196-
# backwards compat isinstancechecks
197-
from adapt.intent import IntentBuilder as _IB
198-
check = isinstance(instance, _IB)
199-
except ImportError:
200-
pass
201-
return check
202-
203-
204-
class IntentBuilder(metaclass=_IntentBuilderMeta):
205-
"""
206-
IntentBuilder, used to construct intent parsers.
207-
208-
Attributes:
209-
at_least_one(list): A list of Entities where one is required.
210-
These are separated into lists so you can have one of (A or B) and
211-
then require one of (D or F).
212-
requires(list): A list of Required Entities
213-
optional(list): A list of optional Entities
214-
excludes(list): A list of forbidden Entities
215-
name(str): Name of intent
216-
217-
Notes:
218-
This is designed to allow construction of intents in one line.
219-
220-
Example:
221-
IntentBuilder("Intent")\
222-
.requires("A")\
223-
.one_of("C","D")\
224-
.optional("G").build()
225-
"""
226-
227-
def __init__(self, intent_name):
228-
"""
229-
Constructor
230-
231-
Args:
232-
intent_name(str): the name of the intents that this parser
233-
parses/validates
234-
"""
235-
self.at_least_one = []
236-
self.requires = []
237-
self.excludes = []
238-
self.optional = []
239-
self.name = intent_name
240-
241-
def one_of(self, *args):
242-
"""
243-
The intent parser should require one of the provided entity types to
244-
validate this clause.
245-
246-
Args:
247-
args(args): *args notation list of entity names
248-
249-
Returns:
250-
self: to continue modifications.
251-
"""
252-
self.at_least_one.append(args)
253-
return self
254-
255-
def require(self, entity_type, attribute_name=None):
256-
"""
257-
The intent parser should require an entity of the provided type.
258-
259-
Args:
260-
entity_type(str): an entity type
261-
attribute_name(str): the name of the attribute on the parsed intent.
262-
Defaults to match entity_type.
263-
264-
Returns:
265-
self: to continue modifications.
266-
"""
267-
if not attribute_name:
268-
attribute_name = entity_type
269-
self.requires += [(entity_type, attribute_name)]
270-
return self
271-
272-
def exclude(self, entity_type):
273-
"""
274-
The intent parser must not contain an entity of the provided type.
275-
276-
Args:
277-
entity_type(str): an entity type
278-
279-
Returns:
280-
self: to continue modifications.
281-
"""
282-
self.excludes.append(entity_type)
283-
return self
284-
285-
def optionally(self, entity_type, attribute_name=None):
286-
"""
287-
Parsed intents from this parser can optionally include an entity of the
288-
provided type.
289-
290-
Args:
291-
entity_type(str): an entity type
292-
attribute_name(str): the name of the attribute on the parsed intent.
293-
Defaults to match entity_type.
294-
295-
Returns:
296-
self: to continue modifications.
297-
"""
298-
if not attribute_name:
299-
attribute_name = entity_type
300-
self.optional += [(entity_type, attribute_name)]
301-
return self
302-
303-
def build(self):
304-
"""
305-
Constructs an intent from the builder's specifications.
306-
307-
:return: an Intent instance.
308-
"""
309-
return Intent(self.name, self.requires,
310-
self.at_least_one, self.optional,
311-
self.excludes)
9+
# OVOS-INTENT-4 keyword-intent *definition* primitives. The canonical, adapt-free
10+
# implementations live in ovos-spec-tools; they are re-exported here so skills keep
11+
# their long-standing `from ovos_workshop.intents import IntentBuilder` import while
12+
# the single source of truth is the spec. The `IntentServiceInterface` producer
13+
# (and the munge_* helpers) below consume these definitions.
14+
from ovos_spec_tools import Intent, IntentBuilder, open_intent_envelope
31215

31316

31417
def to_alnum(skill_id: str) -> str:
@@ -673,15 +376,3 @@ def __contains__(self, val):
673376
Checks if an intent name has been registered.
674377
"""
675378
return val in [i[0] for i in self.registered_intents]
676-
677-
678-
def open_intent_envelope(message):
679-
"""
680-
Convert dictionary received over messagebus to Intent.
681-
"""
682-
intent_dict = message.data
683-
return Intent(intent_dict.get('name'),
684-
intent_dict.get('requires'),
685-
intent_dict.get('at_least_one'),
686-
intent_dict.get('optional'),
687-
intent_dict.get('excludes'))

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ dependencies = [
1414
"ovos-utils>= 0.7.0,<1.0.0",
1515
"ovos_bus_client>=2.2.0a1,<3.0.0",
1616
"ovos-config>=0.0.12,<3.0.0",
17-
"ovos-spec-tools>=0.9.0a1",
17+
"ovos-spec-tools>=0.16.1a2", # carries the adapt-free intent-definition primitives (OVOS-INTENT-4)
1818
"ovos-yes-no-plugin>=0.3.0,<1.0.0",
1919
"ovos-option-matcher-fuzzy-plugin>=0.0.1,<1.0.0",
2020
"ovos-number-parser>=0.0.1,<1.0.0",

0 commit comments

Comments
 (0)