Skip to content

Commit 3c1cbca

Browse files
authored
Merge pull request #52 from OHF-Voice/synesthesiam-20260514-intent-handle
Extend intent recognition and handling
2 parents 64f5c34 + 4bf1ac9 commit 3c1cbca

8 files changed

Lines changed: 188 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
# Changelog
22

3-
## Unreleased
3+
## 1.9.0
44

5+
- Add `intents-start` and `intents-end` to allow for multiple intents to be recognized
6+
- Add `supports_home_control` flag to indicate an intent handling program may execute home control actions
7+
- Add `user-event` for user-defined events
58
- Add `wake_words` query parameter to wake server
69

710
## 1.8.0

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ Describe available services.
124124
* `description` - human-readable description (string, optional)
125125
* `version` - version of the model (string, optional)
126126
* `supports_handled_streaming` - true if program can stream response chunks
127+
* `supports_home_control` - true if program may execute home control actions during handling
127128
* `intent` - list intent recognition services (optional)
128129
* `models` - list of available models (required)
129130
* `name` - unique name (required)
@@ -236,6 +237,14 @@ Recognizes intents from text.
236237
* `not-recognized` - response indicating no intent was recognized
237238
* `text` - response for user (string, optional)
238239
* `context` - context for next interactions (object, optional)
240+
241+
Multiple intents:
242+
243+
1. `intents-start` - signals one or more intents will follow
244+
* `context` - context from previous interactions (object, optional)
245+
2. `intent` - one or more intents sent as normal
246+
* Older clients will only process the first intent
247+
3. `intents-stop` - end of intents
239248

240249
### Intent Handling
241250

@@ -311,6 +320,13 @@ Pipelines are run on the server, but can be triggered remotely from the server a
311320
* `timer-finished` - timer finished without being cancelled
312321
* `id` - unique id of timer (string, required)
313322

323+
### Miscellaneous
324+
325+
* `user-event` - user-defined event
326+
* `name` - name of the user event type (string, required)
327+
* `data` - data for user event (object, optional)
328+
* `context` - context from previous interactions (object, optional)
329+
314330
## Event Flow
315331

316332
* → is an event from client to server
@@ -400,6 +416,15 @@ Streaming:
400416
2. ← `intent` if successful
401417
3. ← `not-recognized` if not successful
402418

419+
For multiple intents:
420+
421+
1. → `recognize` (required)
422+
2. → `intents-start` if successful
423+
2. → `intent` if successful
424+
* One or more intents
425+
2. → `intents-end` if successful
426+
3. ← `not-recognized` if not successful
427+
403428
### Intent Handling
404429

405430
For structured intents:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "wyoming"
3-
version = "1.8.0"
3+
version = "1.9.0"
44
description = "Peer-to-peer protocol for voice assistants"
55
readme = "README.md"
66
requires-python = ">=3.8"

tests/test_eventable.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ def all_unique_eventables() -> List[Type[Eventable]]:
135135
)
136136
],
137137
supports_handled_streaming=True,
138+
supports_home_control=True,
138139
)
139140
],
140141
"intent": [
@@ -250,6 +251,8 @@ def all_unique_eventables() -> List[Type[Eventable]]:
250251
"context": TEST_CONTEXT,
251252
},
252253
"NotRecognized": {"text": TEST_TEXT, "context": TEST_CONTEXT},
254+
"IntentsStart": {"context": TEST_CONTEXT},
255+
"IntentsStop": {"context": TEST_CONTEXT},
253256
# handle
254257
"Handled": {
255258
"text": TEST_TEXT,
@@ -287,6 +290,11 @@ def all_unique_eventables() -> List[Type[Eventable]]:
287290
"Ping": {},
288291
"Pong": {},
289292
"RunPipeline": {"start_stage": PipelineStage.ASR, "end_stage": PipelineStage.TTS},
293+
"UserEvent": {
294+
"name": TEST_NAME,
295+
"data": {TEST_ID: TEST_TEXT},
296+
"context": TEST_CONTEXT,
297+
},
290298
}
291299

292300

wyoming/http/intent_server.py

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22

33
import logging
44
from pathlib import Path
5-
from typing import Any, Dict
5+
from typing import Any, Dict, List, Union
66

77
from flask import request
88

99
from wyoming.asr import Transcript
1010
from wyoming.client import AsyncClient
1111
from wyoming.error import Error
1212
from wyoming.handle import Handled, NotHandled
13-
from wyoming.intent import Intent, NotRecognized
13+
from wyoming.intent import Intent, IntentsStart, IntentsStop, NotRecognized
1414

1515
from .shared import get_app, get_argument_parser
1616

@@ -27,7 +27,7 @@ def main():
2727
app = get_app("intent", CONF_PATH, args)
2828

2929
@app.route("/api/recognize-intent", methods=["POST", "GET"])
30-
async def api_stt() -> Dict[str, Any]:
30+
async def api_recognize_intent() -> Union[Dict[str, Any], List[Dict[str, Any]]]:
3131
uri = request.args.get("uri", args.uri)
3232
if not uri:
3333
raise ValueError("URI is required")
@@ -45,42 +45,60 @@ async def api_stt() -> Dict[str, Any]:
4545
async with AsyncClient.from_uri(uri) as client:
4646
await client.write_event(Transcript(text=text, language=language).event())
4747

48+
type_name = "unknown"
49+
results: List[Dict[str, Any]] = []
50+
is_intent_list = False
51+
4852
while True:
4953
event = await client.read_event()
5054
if event is None:
5155
raise RuntimeError("Client disconnected")
5256

53-
success = False
54-
type_name = "unknown"
55-
result: Dict[str, Any] = {}
57+
if IntentsStart.is_type(event.type):
58+
is_intent_list = True
59+
continue
5660

5761
if Intent.is_type(event.type):
58-
success = True
5962
type_name = "intent"
6063
intent = Intent.from_event(event)
61-
result = intent.to_dict()
62-
elif Handled.is_type(event.type):
63-
success = True
64+
results.append(intent.to_dict())
65+
if not is_intent_list:
66+
break
67+
68+
if IntentsStop.is_type(event.type):
69+
break
70+
71+
if Handled.is_type(event.type):
6472
type_name = "handled"
6573
handled = Handled.from_event(event)
66-
result = handled.to_dict()
67-
elif NotRecognized.is_type(event.type):
68-
success = False
74+
results.append(handled.to_dict())
75+
break
76+
77+
if NotRecognized.is_type(event.type):
6978
type_name = "not-recognized"
7079
not_recognized = NotRecognized.from_event(event)
71-
result = not_recognized.to_dict()
72-
elif NotHandled.is_type(event.type):
73-
success = False
80+
results.append(not_recognized.to_dict())
81+
break
82+
83+
if NotHandled.is_type(event.type):
7484
type_name = "not-handled"
7585
not_handled = NotHandled.from_event(event)
76-
result = not_handled.to_dict()
77-
elif Error.is_type(event.type):
86+
results.append(not_handled.to_dict())
87+
break
88+
89+
if Error.is_type(event.type):
7890
error = Error.from_event(event)
7991
raise RuntimeError(
8092
f"Unexpected error from client: code={error.code}, text={error.text}"
8193
)
8294

83-
return {"success": success, "type": type_name, "result": result}
95+
if len(results) == 0:
96+
return {"success": False, "type": "unknown", "result": {}}
97+
98+
if len(results) == 1:
99+
return {"success": True, "type": type_name, "result": results[0]}
100+
101+
return {"success": True, "type": type_name, "result": results}
84102

85103
app.run(args.host, args.port)
86104

wyoming/info.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,9 @@ class HandleProgram(Artifact):
135135
supports_handled_streaming: bool = False
136136
"""True if handled response streaming events are supported."""
137137

138+
supports_home_control: bool = False
139+
"""True if the service may execute home control actions during handling."""
140+
138141

139142
# -----------------------------------------------------------------------------
140143

wyoming/intent.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
_RECOGNIZE_TYPE = "recognize"
1010
_INTENT_TYPE = "intent"
1111
_NOT_RECOGNIZED_TYPE = "not-recognized"
12+
_INTENTS_START_TYPE = "intents-start"
13+
_INTENTS_STOP_TYPE = "intents-stop"
1214

1315

1416
@dataclass
@@ -137,3 +139,69 @@ def from_event(event: Event) -> "NotRecognized":
137139
return NotRecognized(
138140
text=event.data.get("text"), context=event.data.get("context")
139141
)
142+
143+
144+
@dataclass
145+
class IntentsStart(Eventable):
146+
"""Start of one or more intents.
147+
148+
Event flow:
149+
intents-start
150+
intent
151+
[intent]...
152+
intents-stop
153+
"""
154+
155+
context: Optional[Dict[str, Any]] = None
156+
"""Context for next interaction."""
157+
158+
@staticmethod
159+
def is_type(event_type: str) -> bool:
160+
return event_type == _INTENTS_START_TYPE
161+
162+
def event(self) -> Event:
163+
data: Dict[str, Any] = {}
164+
if self.context is not None:
165+
data["context"] = self.context
166+
167+
return Event(type=_INTENTS_START_TYPE, data=data)
168+
169+
@staticmethod
170+
def from_event(event: Event) -> "IntentsStart":
171+
if not event.data:
172+
return IntentsStart()
173+
174+
return IntentsStart(context=event.data.get("context"))
175+
176+
177+
@dataclass
178+
class IntentsStop(Eventable):
179+
"""End of intent series.
180+
181+
Event flow:
182+
intents-start
183+
intent
184+
[intent]...
185+
intents-stop
186+
"""
187+
188+
context: Optional[Dict[str, Any]] = None
189+
"""Context for next interaction."""
190+
191+
@staticmethod
192+
def is_type(event_type: str) -> bool:
193+
return event_type == _INTENTS_STOP_TYPE
194+
195+
def event(self) -> Event:
196+
data: Dict[str, Any] = {}
197+
if self.context is not None:
198+
data["context"] = self.context
199+
200+
return Event(type=_INTENTS_STOP_TYPE, data=data)
201+
202+
@staticmethod
203+
def from_event(event: Event) -> "IntentsStop":
204+
if not event.data:
205+
return IntentsStop()
206+
207+
return IntentsStop(context=event.data.get("context"))

wyoming/user_event.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""User-defined events."""
2+
3+
from dataclasses import dataclass
4+
from typing import Any, Dict, Optional
5+
6+
from .event import Event, Eventable
7+
8+
_USER_EVENT_TYPE = "user-event"
9+
10+
11+
@dataclass
12+
class UserEvent(Eventable):
13+
"""User-defined event."""
14+
15+
name: str
16+
"""Name of user event type."""
17+
18+
data: Optional[Dict[str, Any]] = None
19+
"""User event data."""
20+
21+
context: Optional[Dict[str, Any]] = None
22+
"""Context from previous interactions."""
23+
24+
@staticmethod
25+
def is_type(event_type: str) -> bool:
26+
return event_type == _USER_EVENT_TYPE
27+
28+
def event(self) -> Event:
29+
data: Dict[str, Any] = {"name": self.name}
30+
if self.data is not None:
31+
data["data"] = self.data
32+
if self.context is not None:
33+
data["context"] = self.context
34+
return Event(type=_USER_EVENT_TYPE, data=data)
35+
36+
@staticmethod
37+
def from_event(event: Event) -> "UserEvent":
38+
return UserEvent(
39+
name=event.data["name"],
40+
data=event.data.get("data"),
41+
context=event.data.get("context"),
42+
)

0 commit comments

Comments
 (0)