nardial 0.4.1__tar.gz → 0.4.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {nardial-0.4.1 → nardial-0.4.2}/PKG-INFO +36 -1
- {nardial-0.4.1 → nardial-0.4.2}/README.md +34 -0
- {nardial-0.4.1 → nardial-0.4.2}/pyproject.toml +2 -1
- nardial-0.4.2/src/nardial/controls.py +184 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/conversation_agent.py +16 -6
- nardial-0.4.2/src/nardial/events/sources.py +68 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/interaction_orchestrator.py +87 -4
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/mini_dialogs.py +17 -5
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/moves.py +11 -3
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/nlu/__init__.py +2 -0
- nardial-0.4.2/src/nardial/providers/nlu/dialogflow.py +65 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/nlu/whisper.py +17 -2
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/screen/web/static/screen.js +11 -1
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/session_manager.py +13 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial.egg-info/PKG-INFO +36 -1
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial.egg-info/SOURCES.txt +4 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial.egg-info/requires.txt +1 -0
- nardial-0.4.2/tests/test_controls.py +319 -0
- {nardial-0.4.1 → nardial-0.4.2}/tests/test_conversation_agent.py +20 -0
- nardial-0.4.2/tests/test_listen_timeout.py +171 -0
- {nardial-0.4.1 → nardial-0.4.2}/tests/test_session_manager.py +23 -0
- nardial-0.4.1/src/nardial/providers/nlu/dialogflow.py +0 -38
- {nardial-0.4.1 → nardial-0.4.2}/LICENSE +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/setup.cfg +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/__init__.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/agenda/__init__.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/agenda/items.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/agenda/resolver.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/agenda/session_plan.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/agenda/slot_bounds.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/authoring/__init__.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/authoring/export_html.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/authoring/factory.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/authoring/loader.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/conversation_state.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/dialog_registry.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/dialog_types.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/eligibility.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/events/__init__.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/events/bus.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/events/types.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/__init__.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/device/__init__.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/device/alphamini.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/device/desktop.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/device/nao.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/device/pepper.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/device/reachy_mini.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/llm/__init__.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/llm/echo.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/llm/openai_gpt.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/nlu/written_keyword.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/screen/__init__.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/screen/null.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/screen/pepper_tablet.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/screen/sic_adapter.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/screen/web/static/screen.css +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/screen/web/static/socket.io.min.js +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/screen/web/templates/index.html +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/tts/__init__.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/tts/cacher.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/tts/elevenlabs.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/tts/google.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/tts/naoqi.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/tts/null.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/vector_store/__init__.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/vector_store/null.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/vector_store/redis_store.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial/user_model.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial.egg-info/dependency_links.txt +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/src/nardial.egg-info/top_level.txt +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/tests/test_agenda_items.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/tests/test_agenda_resolver.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/tests/test_authoring_factory.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/tests/test_conversation_state.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/tests/test_dialog_registry.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/tests/test_dialog_types.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/tests/test_eligibility_rules.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/tests/test_event_bus.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/tests/test_llm.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/tests/test_mini_dialogs_extra.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/tests/test_moves.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/tests/test_providers.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/tests/test_session_plan.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/tests/test_slot_bounds.py +0 -0
- {nardial-0.4.1 → nardial-0.4.2}/tests/test_type_definitions.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nardial
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Nardial Dialog System
|
|
5
5
|
Author-email: Mike Ligthart <m.e.u.ligthart@vu.nl>
|
|
6
6
|
Maintainer: Clara Fayyad, Landon Brown, Evelyne Prodescu, Eva Biesot
|
|
@@ -9,6 +9,7 @@ Requires-Python: >=3.12
|
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
License-File: LICENSE
|
|
11
11
|
Requires-Dist: social-interaction-cloud
|
|
12
|
+
Requires-Dist: pynput>=1.7
|
|
12
13
|
Provides-Extra: google-tts
|
|
13
14
|
Requires-Dist: social-interaction-cloud[google-tts]; extra == "google-tts"
|
|
14
15
|
Provides-Extra: elevenlabs
|
|
@@ -269,6 +270,37 @@ Then pass `device` and `tts` to `ConversationAgent` as above.
|
|
|
269
270
|
|
|
270
271
|
---
|
|
271
272
|
|
|
273
|
+
### Pause and skip controls
|
|
274
|
+
|
|
275
|
+
Bind keys to pause the interaction or to skip a listen that takes too long by passing `controls` in an `InteractionConfig`. Keys work globally (the terminal does not need focus), with or without `SessionManager`:
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
from nardial.interaction_orchestrator import InteractionConfig
|
|
279
|
+
|
|
280
|
+
agent = ConversationAgent(
|
|
281
|
+
..., # device and providers as in the setups above
|
|
282
|
+
int_config=InteractionConfig(
|
|
283
|
+
controls={
|
|
284
|
+
"start_paused": True, # wait for the pause key before starting
|
|
285
|
+
"toggle_pause": ["space"], # pause / resume
|
|
286
|
+
"skip_listen": ["s"], # stop a listen that takes too long
|
|
287
|
+
},
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
| Setting | Effect |
|
|
293
|
+
|---|---|
|
|
294
|
+
| `toggle_pause` | Pauses the interaction. Speech in progress is stopped and spoken again from the beginning after resume. Listening in progress is stopped, what was heard so far is kept, and the dialog continues after the listen once resumed. Works in every dialog type, including LLM dialogs. |
|
|
295
|
+
| `skip_listen` | Stops the current listen early. What was said so far is still recognised and used as the answer: Dialogflow gets a `StopListeningMessage`, and local Whisper gets an end-of-audio signal and then transcribes what it recorded (this can take a few seconds; Whisper waits up to 30 s for it, which a faster model can lower with `LocalWhisperNLUProvider(..., stop_grace_seconds=5)`). The written keyword provider cannot stop early and gives up after 3 seconds with an empty answer. |
|
|
296
|
+
| `start_paused` | `true` makes the interaction wait for `toggle_pause` before it starts. |
|
|
297
|
+
|
|
298
|
+
A question whose listen was stopped by a pause or skip is not asked again, even when the answer is empty: the dialog continues with whatever was heard, and an empty answer follows the move's `default_outcome`.
|
|
299
|
+
|
|
300
|
+
Key names are single characters (`"p"`) or [pynput key names](https://pynput.readthedocs.io/en/latest/keyboard.html#pynput.keyboard.Key) (`"space"`, `"enter"`, `"f5"`). Moves that are not speech or listening (e.g. `timed_wait`, `wait_for_button`) are not paused; the pause takes effect at the next speech or listen.
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
272
304
|
### Using SessionManager
|
|
273
305
|
|
|
274
306
|
`SessionManager` wraps `ConversationAgent` and adds dialog loading, eligibility checking, and session state:
|
|
@@ -670,6 +702,7 @@ Asks a free-text question and listens for any spoken reply. The answer can be st
|
|
|
670
702
|
| `add_interest_from_answer` | boolean | | If `true`, adds the answer to the user's topics of interest |
|
|
671
703
|
| `llm_followup` | string | | System prompt for an LLM-generated follow-up sentence after the user replies |
|
|
672
704
|
| `llm_extract_entity` | boolean | | If `true`, the LLM extracts the key entity from the answer (e.g. `"dog"` from `"my favorite animal is a dog"`) before it is stored; unrelated answers store nothing. Default `false` |
|
|
705
|
+
| `timeout` | number | | Seconds to listen for an answer. Defaults to `InteractionConfig(listen_timeout=...)` (10 s) |
|
|
673
706
|
|
|
674
707
|
```json
|
|
675
708
|
{
|
|
@@ -695,6 +728,7 @@ Asks a yes/no question. The detected intent (`"yes"`, `"no"`, `"dontknow"`) driv
|
|
|
695
728
|
| `default_outcome` | string | | Outcome label used as fallback |
|
|
696
729
|
| `add_interest` | string | | Topic added to interest list when the user answers `"yes"` |
|
|
697
730
|
| `llm_followup` | string | | System prompt for an LLM-generated follow-up sentence |
|
|
731
|
+
| `timeout` | number | | Seconds to listen for an answer. Defaults to `InteractionConfig(listen_timeout=...)` (10 s) |
|
|
698
732
|
|
|
699
733
|
```json
|
|
700
734
|
{
|
|
@@ -723,6 +757,7 @@ Presents a multiple-choice question. The selected option drives branching and ca
|
|
|
723
757
|
| `default_outcome` | string | | Outcome label when nothing matches |
|
|
724
758
|
| `add_interest_from_variable` | string | | After storing, adds the named variable's value as a topic of interest |
|
|
725
759
|
| `llm_followup` | string | | System prompt for an LLM-generated follow-up sentence |
|
|
760
|
+
| `timeout` | number | | Seconds to listen for an answer. Defaults to `InteractionConfig(listen_timeout=...)` (10 s) |
|
|
726
761
|
|
|
727
762
|
```json
|
|
728
763
|
{
|
|
@@ -241,6 +241,37 @@ Then pass `device` and `tts` to `ConversationAgent` as above.
|
|
|
241
241
|
|
|
242
242
|
---
|
|
243
243
|
|
|
244
|
+
### Pause and skip controls
|
|
245
|
+
|
|
246
|
+
Bind keys to pause the interaction or to skip a listen that takes too long by passing `controls` in an `InteractionConfig`. Keys work globally (the terminal does not need focus), with or without `SessionManager`:
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
from nardial.interaction_orchestrator import InteractionConfig
|
|
250
|
+
|
|
251
|
+
agent = ConversationAgent(
|
|
252
|
+
..., # device and providers as in the setups above
|
|
253
|
+
int_config=InteractionConfig(
|
|
254
|
+
controls={
|
|
255
|
+
"start_paused": True, # wait for the pause key before starting
|
|
256
|
+
"toggle_pause": ["space"], # pause / resume
|
|
257
|
+
"skip_listen": ["s"], # stop a listen that takes too long
|
|
258
|
+
},
|
|
259
|
+
),
|
|
260
|
+
)
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
| Setting | Effect |
|
|
264
|
+
|---|---|
|
|
265
|
+
| `toggle_pause` | Pauses the interaction. Speech in progress is stopped and spoken again from the beginning after resume. Listening in progress is stopped, what was heard so far is kept, and the dialog continues after the listen once resumed. Works in every dialog type, including LLM dialogs. |
|
|
266
|
+
| `skip_listen` | Stops the current listen early. What was said so far is still recognised and used as the answer: Dialogflow gets a `StopListeningMessage`, and local Whisper gets an end-of-audio signal and then transcribes what it recorded (this can take a few seconds; Whisper waits up to 30 s for it, which a faster model can lower with `LocalWhisperNLUProvider(..., stop_grace_seconds=5)`). The written keyword provider cannot stop early and gives up after 3 seconds with an empty answer. |
|
|
267
|
+
| `start_paused` | `true` makes the interaction wait for `toggle_pause` before it starts. |
|
|
268
|
+
|
|
269
|
+
A question whose listen was stopped by a pause or skip is not asked again, even when the answer is empty: the dialog continues with whatever was heard, and an empty answer follows the move's `default_outcome`.
|
|
270
|
+
|
|
271
|
+
Key names are single characters (`"p"`) or [pynput key names](https://pynput.readthedocs.io/en/latest/keyboard.html#pynput.keyboard.Key) (`"space"`, `"enter"`, `"f5"`). Moves that are not speech or listening (e.g. `timed_wait`, `wait_for_button`) are not paused; the pause takes effect at the next speech or listen.
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
244
275
|
### Using SessionManager
|
|
245
276
|
|
|
246
277
|
`SessionManager` wraps `ConversationAgent` and adds dialog loading, eligibility checking, and session state:
|
|
@@ -642,6 +673,7 @@ Asks a free-text question and listens for any spoken reply. The answer can be st
|
|
|
642
673
|
| `add_interest_from_answer` | boolean | | If `true`, adds the answer to the user's topics of interest |
|
|
643
674
|
| `llm_followup` | string | | System prompt for an LLM-generated follow-up sentence after the user replies |
|
|
644
675
|
| `llm_extract_entity` | boolean | | If `true`, the LLM extracts the key entity from the answer (e.g. `"dog"` from `"my favorite animal is a dog"`) before it is stored; unrelated answers store nothing. Default `false` |
|
|
676
|
+
| `timeout` | number | | Seconds to listen for an answer. Defaults to `InteractionConfig(listen_timeout=...)` (10 s) |
|
|
645
677
|
|
|
646
678
|
```json
|
|
647
679
|
{
|
|
@@ -667,6 +699,7 @@ Asks a yes/no question. The detected intent (`"yes"`, `"no"`, `"dontknow"`) driv
|
|
|
667
699
|
| `default_outcome` | string | | Outcome label used as fallback |
|
|
668
700
|
| `add_interest` | string | | Topic added to interest list when the user answers `"yes"` |
|
|
669
701
|
| `llm_followup` | string | | System prompt for an LLM-generated follow-up sentence |
|
|
702
|
+
| `timeout` | number | | Seconds to listen for an answer. Defaults to `InteractionConfig(listen_timeout=...)` (10 s) |
|
|
670
703
|
|
|
671
704
|
```json
|
|
672
705
|
{
|
|
@@ -695,6 +728,7 @@ Presents a multiple-choice question. The selected option drives branching and ca
|
|
|
695
728
|
| `default_outcome` | string | | Outcome label when nothing matches |
|
|
696
729
|
| `add_interest_from_variable` | string | | After storing, adds the named variable's value as a topic of interest |
|
|
697
730
|
| `llm_followup` | string | | System prompt for an LLM-generated follow-up sentence |
|
|
731
|
+
| `timeout` | number | | Seconds to listen for an answer. Defaults to `InteractionConfig(listen_timeout=...)` (10 s) |
|
|
698
732
|
|
|
699
733
|
```json
|
|
700
734
|
{
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "nardial"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.2"
|
|
8
8
|
description = "Nardial Dialog System"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -23,6 +23,7 @@ maintainers = [
|
|
|
23
23
|
|
|
24
24
|
dependencies = [
|
|
25
25
|
"social-interaction-cloud",
|
|
26
|
+
"pynput>=1.7",
|
|
26
27
|
]
|
|
27
28
|
|
|
28
29
|
[project.optional-dependencies]
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Session-wide controls: pause/resume and skipping a listen, bound to input triggers.
|
|
2
|
+
|
|
3
|
+
Example config (passed as ``InteractionConfig(controls=...)``)::
|
|
4
|
+
|
|
5
|
+
{
|
|
6
|
+
"start_paused": True, # wait for toggle_pause before the first speech or listen
|
|
7
|
+
"toggle_pause": ["space"], # keyboard keys...
|
|
8
|
+
"skip_listen": ["s"],
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
A trigger is a keyboard key name, or ``{"<event type>": "<value>"}`` for other
|
|
12
|
+
input sources (e.g. ``{"robot_button": "head_front"}`` once such a source exists).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import logging
|
|
19
|
+
|
|
20
|
+
from nardial.events.bus import EventBus
|
|
21
|
+
from nardial.events.sources import KeyboardSource
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
ACTIONS = ("toggle_pause", "skip_listen")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SessionControl:
|
|
29
|
+
"""Maps input events to actions and lets the orchestrator honour pause/skip."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, config: dict):
|
|
32
|
+
config = dict(config or {})
|
|
33
|
+
unknown = set(config) - set(ACTIONS) - {"start_paused"}
|
|
34
|
+
if unknown:
|
|
35
|
+
raise ValueError(f"Unknown controls: {sorted(unknown)}. Allowed: {list(ACTIONS)} and 'start_paused'.")
|
|
36
|
+
|
|
37
|
+
# (event type, value) -> action
|
|
38
|
+
self.bindings: dict[tuple[str, str], str] = {}
|
|
39
|
+
for action in ACTIONS:
|
|
40
|
+
for trigger in config.get(action) or []:
|
|
41
|
+
if isinstance(trigger, str):
|
|
42
|
+
trigger = {KeyboardSource.event_type: trigger}
|
|
43
|
+
for event_type, value in trigger.items():
|
|
44
|
+
self.bindings[(event_type, str(value).lower())] = action
|
|
45
|
+
|
|
46
|
+
event_types = {event_type for event_type, _ in self.bindings}
|
|
47
|
+
self.sources = [KeyboardSource()] if KeyboardSource.event_type in event_types else []
|
|
48
|
+
|
|
49
|
+
self._running = asyncio.Event()
|
|
50
|
+
self._paused = asyncio.Event()
|
|
51
|
+
self._skip = asyncio.Event()
|
|
52
|
+
if config.get("start_paused"):
|
|
53
|
+
self._paused.set()
|
|
54
|
+
else:
|
|
55
|
+
self._running.set()
|
|
56
|
+
|
|
57
|
+
self._bus: EventBus | None = None
|
|
58
|
+
self._sub = None
|
|
59
|
+
self._task: asyncio.Task | None = None
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
# Actions
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def started(self) -> bool:
|
|
67
|
+
return self._task is not None
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def paused(self) -> bool:
|
|
71
|
+
return self._paused.is_set()
|
|
72
|
+
|
|
73
|
+
def pause(self) -> None:
|
|
74
|
+
self._running.clear()
|
|
75
|
+
self._paused.set()
|
|
76
|
+
print(f"[INFO] Session paused. Press {self._triggers_for('toggle_pause')} to continue.")
|
|
77
|
+
|
|
78
|
+
def resume(self) -> None:
|
|
79
|
+
self._paused.clear()
|
|
80
|
+
self._running.set()
|
|
81
|
+
print("[INFO] Session resumed.")
|
|
82
|
+
|
|
83
|
+
def toggle_pause(self) -> None:
|
|
84
|
+
self.resume() if self.paused else self.pause()
|
|
85
|
+
|
|
86
|
+
def skip_listen(self) -> None:
|
|
87
|
+
"""Stop the current listen early, keeping what was heard so far. No-op when not listening."""
|
|
88
|
+
self._skip.set()
|
|
89
|
+
|
|
90
|
+
def action_for(self, event) -> str | None:
|
|
91
|
+
"""The action bound to ``event``, or None."""
|
|
92
|
+
if not isinstance(event.data, dict):
|
|
93
|
+
return None
|
|
94
|
+
return self.bindings.get((event.type, str(event.data.get("value")).lower()))
|
|
95
|
+
|
|
96
|
+
def handle(self, event) -> None:
|
|
97
|
+
action = self.action_for(event)
|
|
98
|
+
if action is not None:
|
|
99
|
+
getattr(self, action)()
|
|
100
|
+
|
|
101
|
+
def _triggers_for(self, action: str) -> str:
|
|
102
|
+
return " / ".join(value for (_, value), a in self.bindings.items() if a == action) or "(no trigger)"
|
|
103
|
+
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
# Lifecycle
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
async def start(self, bus: EventBus) -> None:
|
|
109
|
+
"""Listen for bound input events on ``bus`` and start the input sources."""
|
|
110
|
+
self._bus = bus
|
|
111
|
+
self._sub = bus.subscribe(lambda ev: self.action_for(ev) is not None)
|
|
112
|
+
self._task = asyncio.create_task(self._consume())
|
|
113
|
+
for source in self.sources:
|
|
114
|
+
source.start(bus)
|
|
115
|
+
if self.paused:
|
|
116
|
+
print(f"[INFO] Session starts paused. Press {self._triggers_for('toggle_pause')} to start.")
|
|
117
|
+
|
|
118
|
+
async def stop(self) -> None:
|
|
119
|
+
for source in self.sources:
|
|
120
|
+
source.stop()
|
|
121
|
+
if self._task is not None:
|
|
122
|
+
self._task.cancel()
|
|
123
|
+
await asyncio.gather(self._task, return_exceptions=True)
|
|
124
|
+
self._task = None
|
|
125
|
+
if self._bus is not None and self._sub is not None:
|
|
126
|
+
self._bus.unsubscribe(self._sub)
|
|
127
|
+
self._sub = None
|
|
128
|
+
|
|
129
|
+
async def _consume(self) -> None:
|
|
130
|
+
while True:
|
|
131
|
+
event = await self._sub.get()
|
|
132
|
+
try:
|
|
133
|
+
self.handle(event)
|
|
134
|
+
except Exception:
|
|
135
|
+
logger.exception("SessionControl: handling %r failed", event)
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
# Used by the orchestrator
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
async def wait_until_running(self) -> None:
|
|
142
|
+
await self._running.wait()
|
|
143
|
+
|
|
144
|
+
async def run(self, make_coro, on_stop=None, stop_grace_seconds: float = 3.0):
|
|
145
|
+
"""Run ``make_coro()`` and return its result, honouring pause and skip.
|
|
146
|
+
|
|
147
|
+
Without ``on_stop`` (speech), a pause cancels the coroutine and starts it
|
|
148
|
+
again from scratch after resume, so the robot repeats the sentence
|
|
149
|
+
instead of finishing it mid-word.
|
|
150
|
+
|
|
151
|
+
With ``on_stop`` (listening), both a pause and ``skip_listen`` call it to
|
|
152
|
+
make the coroutine finish early (e.g. stop listening, keeping the audio
|
|
153
|
+
so far) and return the coroutine's result. After a pause, that result is
|
|
154
|
+
held until resume, so the dialog continues after the listen. If the
|
|
155
|
+
coroutine has not finished within ``stop_grace_seconds`` it is cancelled
|
|
156
|
+
and ``None`` is returned.
|
|
157
|
+
"""
|
|
158
|
+
while True:
|
|
159
|
+
await self._running.wait()
|
|
160
|
+
self._skip.clear()
|
|
161
|
+
task = asyncio.ensure_future(make_coro())
|
|
162
|
+
waiters = {asyncio.ensure_future(self._paused.wait())}
|
|
163
|
+
if on_stop is not None:
|
|
164
|
+
waiters.add(asyncio.ensure_future(self._skip.wait()))
|
|
165
|
+
stopped = False
|
|
166
|
+
try:
|
|
167
|
+
await asyncio.wait({task, *waiters}, return_when=asyncio.FIRST_COMPLETED)
|
|
168
|
+
if task.done():
|
|
169
|
+
return task.result()
|
|
170
|
+
if on_stop is not None:
|
|
171
|
+
# Paused or skipped: ask the work to finish early and keep its result.
|
|
172
|
+
on_stop()
|
|
173
|
+
await asyncio.wait({task}, timeout=stop_grace_seconds)
|
|
174
|
+
stopped = True
|
|
175
|
+
finally:
|
|
176
|
+
for waiter in waiters:
|
|
177
|
+
waiter.cancel()
|
|
178
|
+
if not task.done():
|
|
179
|
+
task.cancel()
|
|
180
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
181
|
+
if stopped:
|
|
182
|
+
await self._running.wait() # after a pause, continue only once resumed
|
|
183
|
+
return None if task.cancelled() else task.result()
|
|
184
|
+
# Paused speech: loop around, wait for resume and start again.
|
|
@@ -112,7 +112,7 @@ class ConversationAgent:
|
|
|
112
112
|
"""
|
|
113
113
|
self.orchestrator.play_animation(animation_name, run_async=run_async)
|
|
114
114
|
|
|
115
|
-
async def ask_yesno(self, question, max_attempts=1, **kwargs):
|
|
115
|
+
async def ask_yesno(self, question, max_attempts=1, timeout=None, **kwargs):
|
|
116
116
|
"""
|
|
117
117
|
Ask a yes/no question and interpret the response via the NLU provider.
|
|
118
118
|
|
|
@@ -122,6 +122,8 @@ class ConversationAgent:
|
|
|
122
122
|
The question to ask the user.
|
|
123
123
|
max_attempts : int, optional
|
|
124
124
|
Number of retries if no valid answer is detected.
|
|
125
|
+
timeout : float, optional
|
|
126
|
+
Seconds to listen for an answer. Defaults to ``InteractionConfig.listen_timeout``.
|
|
125
127
|
|
|
126
128
|
Returns
|
|
127
129
|
-------
|
|
@@ -131,7 +133,7 @@ class ConversationAgent:
|
|
|
131
133
|
attempts = 0
|
|
132
134
|
while attempts < max_attempts:
|
|
133
135
|
await self.say(question, **kwargs)
|
|
134
|
-
result = await self.orchestrator.listen(context={'answer_yesno': 1})
|
|
136
|
+
result = await self.orchestrator.listen(timeout=timeout, context={'answer_yesno': 1})
|
|
135
137
|
if result.intent:
|
|
136
138
|
print(f'context: answer_yesno, recognized_intent: {result.intent}')
|
|
137
139
|
if result.intent == INTENT_YESNO_YES:
|
|
@@ -140,10 +142,12 @@ class ConversationAgent:
|
|
|
140
142
|
return "no"
|
|
141
143
|
elif result.intent == INTENT_YESNO_DONTKNOW:
|
|
142
144
|
return "dontknow"
|
|
145
|
+
if result.interrupted:
|
|
146
|
+
break # stopped by pause/skip: continue the dialog instead of asking again
|
|
143
147
|
attempts += 1
|
|
144
148
|
return None
|
|
145
149
|
|
|
146
|
-
async def ask_open(self, question, max_attempts=2, **kwargs):
|
|
150
|
+
async def ask_open(self, question, max_attempts=2, timeout=None, **kwargs):
|
|
147
151
|
"""
|
|
148
152
|
Ask an open-ended question and return the user's spoken response.
|
|
149
153
|
|
|
@@ -153,6 +157,8 @@ class ConversationAgent:
|
|
|
153
157
|
The question to ask.
|
|
154
158
|
max_attempts : int, optional
|
|
155
159
|
Number of retries if no response is captured.
|
|
160
|
+
timeout : float, optional
|
|
161
|
+
Seconds to listen for an answer. Defaults to ``InteractionConfig.listen_timeout``.
|
|
156
162
|
|
|
157
163
|
Returns
|
|
158
164
|
-------
|
|
@@ -162,13 +168,15 @@ class ConversationAgent:
|
|
|
162
168
|
attempts = 0
|
|
163
169
|
while attempts < max_attempts:
|
|
164
170
|
await self.say(question, **kwargs)
|
|
165
|
-
result = await self.orchestrator.listen()
|
|
171
|
+
result = await self.orchestrator.listen(timeout=timeout)
|
|
166
172
|
if result.transcript:
|
|
167
173
|
return result.transcript
|
|
174
|
+
if result.interrupted:
|
|
175
|
+
break # stopped by pause/skip: continue the dialog instead of asking again
|
|
168
176
|
attempts += 1
|
|
169
177
|
return None
|
|
170
178
|
|
|
171
|
-
async def ask_options(self, question, options, max_attempts=2, **kwargs):
|
|
179
|
+
async def ask_options(self, question, options, max_attempts=2, timeout=None, **kwargs):
|
|
172
180
|
"""
|
|
173
181
|
Ask a question and match the response against a set of predefined options.
|
|
174
182
|
|
|
@@ -180,6 +188,8 @@ class ConversationAgent:
|
|
|
180
188
|
List of expected keywords/options to match against the response.
|
|
181
189
|
max_attempts : int, optional
|
|
182
190
|
Number of retries.
|
|
191
|
+
timeout : float, optional
|
|
192
|
+
Seconds to listen for an answer. Defaults to ``InteractionConfig.listen_timeout``.
|
|
183
193
|
|
|
184
194
|
Returns
|
|
185
195
|
-------
|
|
@@ -190,7 +200,7 @@ class ConversationAgent:
|
|
|
190
200
|
-----
|
|
191
201
|
Matching is case-insensitive and based on substring presence.
|
|
192
202
|
"""
|
|
193
|
-
answer = await self.ask_open(question, max_attempts=max_attempts, **kwargs)
|
|
203
|
+
answer = await self.ask_open(question, max_attempts=max_attempts, timeout=timeout, **kwargs)
|
|
194
204
|
if answer:
|
|
195
205
|
answer_lower = answer.lower()
|
|
196
206
|
for opt in options:
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Input sources: things outside the dialog (keys, robot buttons, ...) that emit bus events.
|
|
2
|
+
|
|
3
|
+
A source only reports *what* happened, e.g. ``Event(type="keyboard", data={"value": "space"})``.
|
|
4
|
+
What that input *does* (pausing, skipping a listen) is decided by
|
|
5
|
+
:class:`~nardial.controls.SessionControl`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
from nardial.events.bus import EventBus
|
|
14
|
+
from nardial.events.types import Event
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InputSource(Protocol):
|
|
20
|
+
"""Emits events onto the session's bus between ``start()`` and ``stop()``."""
|
|
21
|
+
|
|
22
|
+
event_type: str
|
|
23
|
+
|
|
24
|
+
def start(self, bus: EventBus) -> None: ...
|
|
25
|
+
|
|
26
|
+
def stop(self) -> None: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def key_name(key) -> str | None:
|
|
30
|
+
"""Readable name for a pynput key: ``"a"``, ``"space"``, ``"f5"``."""
|
|
31
|
+
char = getattr(key, "char", None)
|
|
32
|
+
if char:
|
|
33
|
+
return char.lower()
|
|
34
|
+
return getattr(key, "name", None)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class KeyboardSource:
|
|
38
|
+
"""Emits ``Event(type="keyboard", data={"value": <key name>})`` for every key press.
|
|
39
|
+
|
|
40
|
+
Uses pynput, which listens globally (the terminal does not need focus) on its
|
|
41
|
+
own thread. If pynput cannot start (e.g. no display on a headless Linux
|
|
42
|
+
machine), keyboard controls are disabled with a warning instead of crashing.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
event_type = "keyboard"
|
|
46
|
+
|
|
47
|
+
def __init__(self):
|
|
48
|
+
self._listener = None
|
|
49
|
+
|
|
50
|
+
def start(self, bus: EventBus) -> None:
|
|
51
|
+
try:
|
|
52
|
+
from pynput import keyboard
|
|
53
|
+
except Exception as e:
|
|
54
|
+
logger.warning("Keyboard controls disabled, pynput is unavailable: %s", e)
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
def on_press(key):
|
|
58
|
+
name = key_name(key)
|
|
59
|
+
if name:
|
|
60
|
+
bus.emit_sync(Event(priority=0, type=self.event_type, source="keyboard", data={"value": name}))
|
|
61
|
+
|
|
62
|
+
self._listener = keyboard.Listener(on_press=on_press)
|
|
63
|
+
self._listener.start()
|
|
64
|
+
|
|
65
|
+
def stop(self) -> None:
|
|
66
|
+
if self._listener is not None:
|
|
67
|
+
self._listener.stop()
|
|
68
|
+
self._listener = None
|
|
@@ -11,6 +11,8 @@ from time import strftime
|
|
|
11
11
|
from sic_framework.core import sic_logging
|
|
12
12
|
from sic_framework.core.sic_application import SICApplication
|
|
13
13
|
|
|
14
|
+
from nardial.controls import SessionControl
|
|
15
|
+
from nardial.events import EventBus
|
|
14
16
|
from nardial.providers.device import DeviceAdapter, AnimationStyle
|
|
15
17
|
from nardial.providers.tts import TTSProvider, _amplify_audio
|
|
16
18
|
from nardial.providers.nlu import NLUProvider, NLUResult
|
|
@@ -26,10 +28,18 @@ class InteractionConfig:
|
|
|
26
28
|
"""
|
|
27
29
|
|
|
28
30
|
def __init__(self, language="en", post_speech_delay=None,
|
|
29
|
-
signal_listening_behavior=True):
|
|
31
|
+
signal_listening_behavior=True, listen_timeout=10, controls=None):
|
|
32
|
+
"""
|
|
33
|
+
:param controls: Optional pause/skip controls, e.g.
|
|
34
|
+
`{"start_paused": True, "toggle_pause": ["space"], "skip_listen": ["s"]}`.
|
|
35
|
+
See `nardial.controls.SessionControl`.
|
|
36
|
+
"""
|
|
30
37
|
self.language = language
|
|
31
38
|
self.post_speech_delay = post_speech_delay
|
|
32
39
|
self.signal_listening_behavior = signal_listening_behavior
|
|
40
|
+
# Default seconds to listen for a user answer; moves can override it with their own "timeout".
|
|
41
|
+
self.listen_timeout = listen_timeout
|
|
42
|
+
self.controls = controls
|
|
33
43
|
self.animated = True
|
|
34
44
|
self.animation_style = AnimationStyle.EXPLANATORY
|
|
35
45
|
self.always_regenerate = False
|
|
@@ -54,6 +64,9 @@ class InteractionConfig:
|
|
|
54
64
|
|
|
55
65
|
|
|
56
66
|
class InteractionOrchestrator:
|
|
67
|
+
# Seconds a stopped listen may take to return its result, used when the NLU provider sets no stop_grace_seconds.
|
|
68
|
+
DEFAULT_STOP_GRACE_SECONDS = 3.0
|
|
69
|
+
|
|
57
70
|
def __init__(self, device: DeviceAdapter, tts_provider: TTSProvider,
|
|
58
71
|
nlu_provider: NLUProvider, llm_provider: LLMProvider | None = None,
|
|
59
72
|
vector_store: VectorStoreProvider | None = None,
|
|
@@ -103,6 +116,9 @@ class InteractionOrchestrator:
|
|
|
103
116
|
self.nlu_provider = nlu_provider
|
|
104
117
|
self.logger.info("Complete and ready for interaction!")
|
|
105
118
|
|
|
119
|
+
# Pause/skip controls from InteractionConfig.controls; started by start_controls().
|
|
120
|
+
self.control = SessionControl(int_config.controls) if int_config.controls else None
|
|
121
|
+
|
|
106
122
|
def start_logging(self, log_id, init_data: dict):
|
|
107
123
|
folder = Path("logs")
|
|
108
124
|
folder.mkdir(parents=True, exist_ok=True)
|
|
@@ -162,6 +178,17 @@ class InteractionOrchestrator:
|
|
|
162
178
|
@InteractionConfig.apply_config_defaults('interaction_conf', ['post_speech_delay', 'animated', 'always_regenerate', 'chunk_audio'])
|
|
163
179
|
async def say(self, text, post_speech_delay=None, animated=False, amplified=False, always_regenerate=False, chunk_audio=False,
|
|
164
180
|
voice_settings=None):
|
|
181
|
+
speak = functools.partial(self._say, text, post_speech_delay=post_speech_delay, animated=animated,
|
|
182
|
+
amplified=amplified, always_regenerate=always_regenerate,
|
|
183
|
+
chunk_audio=chunk_audio, voice_settings=voice_settings)
|
|
184
|
+
control = await self._active_control()
|
|
185
|
+
if control is None:
|
|
186
|
+
return await speak()
|
|
187
|
+
# A pause stops the sentence; it is spoken again from the start after resume.
|
|
188
|
+
return await control.run(speak)
|
|
189
|
+
|
|
190
|
+
async def _say(self, text, post_speech_delay, animated, amplified, always_regenerate, chunk_audio,
|
|
191
|
+
voice_settings):
|
|
165
192
|
if animated:
|
|
166
193
|
self.device.play_speaking_animation(self.interaction_conf.animation_style)
|
|
167
194
|
try:
|
|
@@ -180,7 +207,33 @@ class InteractionOrchestrator:
|
|
|
180
207
|
if post_speech_delay and post_speech_delay > 0:
|
|
181
208
|
await asyncio.sleep(post_speech_delay)
|
|
182
209
|
|
|
183
|
-
async def listen(self, context=None, timeout=
|
|
210
|
+
async def listen(self, context=None, timeout=None) -> NLUResult:
|
|
211
|
+
if timeout is None:
|
|
212
|
+
timeout = self.interaction_conf.listen_timeout
|
|
213
|
+
|
|
214
|
+
hear = functools.partial(self._listen, context=context, timeout=timeout)
|
|
215
|
+
control = await self._active_control()
|
|
216
|
+
if control is None:
|
|
217
|
+
return await hear()
|
|
218
|
+
# A pause or skip_listen stops listening (Dialogflow sends StopListeningMessage), so the
|
|
219
|
+
# audio heard so far is still recognised. After a pause the dialog continues after the listen.
|
|
220
|
+
stopped = False
|
|
221
|
+
|
|
222
|
+
def stop():
|
|
223
|
+
nonlocal stopped
|
|
224
|
+
stopped = True
|
|
225
|
+
self.nlu_provider.cancel()
|
|
226
|
+
|
|
227
|
+
# How long to wait for the result after stopping; providers that transcribe after
|
|
228
|
+
# recording (Whisper) need longer than the default.
|
|
229
|
+
stop_grace_seconds = getattr(self.nlu_provider, "stop_grace_seconds", self.DEFAULT_STOP_GRACE_SECONDS)
|
|
230
|
+
result = await control.run(hear, on_stop=stop, stop_grace_seconds=stop_grace_seconds)
|
|
231
|
+
if result is None:
|
|
232
|
+
result = NLUResult(transcript="")
|
|
233
|
+
result.interrupted = stopped
|
|
234
|
+
return result
|
|
235
|
+
|
|
236
|
+
async def _listen(self, context, timeout) -> NLUResult:
|
|
184
237
|
if self.interaction_conf.signal_listening_behavior:
|
|
185
238
|
self.device.signal_listening(start=True)
|
|
186
239
|
try:
|
|
@@ -190,8 +243,9 @@ class InteractionOrchestrator:
|
|
|
190
243
|
except asyncio.CancelledError:
|
|
191
244
|
self.nlu_provider.cancel()
|
|
192
245
|
raise
|
|
193
|
-
|
|
194
|
-
self.
|
|
246
|
+
finally:
|
|
247
|
+
if self.interaction_conf.signal_listening_behavior:
|
|
248
|
+
self.device.signal_listening(start=False)
|
|
195
249
|
if result.transcript:
|
|
196
250
|
self.log_utterance(speaker='user', text=result.transcript)
|
|
197
251
|
await self._push_user_transcript(result.transcript)
|
|
@@ -259,7 +313,35 @@ class InteractionOrchestrator:
|
|
|
259
313
|
def play_motion(self, motion_name):
|
|
260
314
|
self.device.play_motion_sequence(motion_name)
|
|
261
315
|
|
|
316
|
+
async def start_controls(self, bus: EventBus | None = None) -> None:
|
|
317
|
+
"""Start the pause/skip controls on ``bus`` (the session's), or on a bus of their own.
|
|
318
|
+
|
|
319
|
+
SessionManager calls this with its session bus. Without a SessionManager the
|
|
320
|
+
controls are started on their own bus at the first ``say``/``listen``.
|
|
321
|
+
"""
|
|
322
|
+
if self.control is None:
|
|
323
|
+
return
|
|
324
|
+
await self.control.stop() # restart cleanly if they were already running on another bus
|
|
325
|
+
if bus is None:
|
|
326
|
+
bus = EventBus()
|
|
327
|
+
bus.set_loop(asyncio.get_running_loop())
|
|
328
|
+
await self.control.start(bus)
|
|
329
|
+
|
|
330
|
+
async def stop_controls(self) -> None:
|
|
331
|
+
if self.control is not None:
|
|
332
|
+
await self.control.stop()
|
|
333
|
+
|
|
334
|
+
async def _active_control(self):
|
|
335
|
+
"""The started controls, or None when no controls are configured."""
|
|
336
|
+
control = getattr(self, "control", None)
|
|
337
|
+
if control is not None and not control.started:
|
|
338
|
+
await self.start_controls()
|
|
339
|
+
return control
|
|
340
|
+
|
|
262
341
|
def disconnect(self):
|
|
342
|
+
if getattr(self, "control", None) is not None:
|
|
343
|
+
for source in self.control.sources:
|
|
344
|
+
source.stop()
|
|
263
345
|
self.tts_provider.close()
|
|
264
346
|
self.device.disconnect()
|
|
265
347
|
if self.vector_store is not None:
|
|
@@ -273,3 +355,4 @@ class InteractionOrchestrator:
|
|
|
273
355
|
|
|
274
356
|
def set_interaction_conf(self, interaction_conf: InteractionConfig):
|
|
275
357
|
self.interaction_conf = interaction_conf
|
|
358
|
+
self.control = SessionControl(interaction_conf.controls) if interaction_conf.controls else None
|