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.
Files changed (86) hide show
  1. {nardial-0.4.1 → nardial-0.4.2}/PKG-INFO +36 -1
  2. {nardial-0.4.1 → nardial-0.4.2}/README.md +34 -0
  3. {nardial-0.4.1 → nardial-0.4.2}/pyproject.toml +2 -1
  4. nardial-0.4.2/src/nardial/controls.py +184 -0
  5. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/conversation_agent.py +16 -6
  6. nardial-0.4.2/src/nardial/events/sources.py +68 -0
  7. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/interaction_orchestrator.py +87 -4
  8. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/mini_dialogs.py +17 -5
  9. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/moves.py +11 -3
  10. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/nlu/__init__.py +2 -0
  11. nardial-0.4.2/src/nardial/providers/nlu/dialogflow.py +65 -0
  12. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/nlu/whisper.py +17 -2
  13. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/screen/web/static/screen.js +11 -1
  14. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/session_manager.py +13 -0
  15. {nardial-0.4.1 → nardial-0.4.2}/src/nardial.egg-info/PKG-INFO +36 -1
  16. {nardial-0.4.1 → nardial-0.4.2}/src/nardial.egg-info/SOURCES.txt +4 -0
  17. {nardial-0.4.1 → nardial-0.4.2}/src/nardial.egg-info/requires.txt +1 -0
  18. nardial-0.4.2/tests/test_controls.py +319 -0
  19. {nardial-0.4.1 → nardial-0.4.2}/tests/test_conversation_agent.py +20 -0
  20. nardial-0.4.2/tests/test_listen_timeout.py +171 -0
  21. {nardial-0.4.1 → nardial-0.4.2}/tests/test_session_manager.py +23 -0
  22. nardial-0.4.1/src/nardial/providers/nlu/dialogflow.py +0 -38
  23. {nardial-0.4.1 → nardial-0.4.2}/LICENSE +0 -0
  24. {nardial-0.4.1 → nardial-0.4.2}/setup.cfg +0 -0
  25. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/__init__.py +0 -0
  26. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/agenda/__init__.py +0 -0
  27. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/agenda/items.py +0 -0
  28. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/agenda/resolver.py +0 -0
  29. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/agenda/session_plan.py +0 -0
  30. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/agenda/slot_bounds.py +0 -0
  31. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/authoring/__init__.py +0 -0
  32. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/authoring/export_html.py +0 -0
  33. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/authoring/factory.py +0 -0
  34. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/authoring/loader.py +0 -0
  35. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/conversation_state.py +0 -0
  36. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/dialog_registry.py +0 -0
  37. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/dialog_types.py +0 -0
  38. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/eligibility.py +0 -0
  39. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/events/__init__.py +0 -0
  40. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/events/bus.py +0 -0
  41. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/events/types.py +0 -0
  42. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/__init__.py +0 -0
  43. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/device/__init__.py +0 -0
  44. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/device/alphamini.py +0 -0
  45. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/device/desktop.py +0 -0
  46. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/device/nao.py +0 -0
  47. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/device/pepper.py +0 -0
  48. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/device/reachy_mini.py +0 -0
  49. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/llm/__init__.py +0 -0
  50. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/llm/echo.py +0 -0
  51. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/llm/openai_gpt.py +0 -0
  52. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/nlu/written_keyword.py +0 -0
  53. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/screen/__init__.py +0 -0
  54. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/screen/null.py +0 -0
  55. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/screen/pepper_tablet.py +0 -0
  56. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/screen/sic_adapter.py +0 -0
  57. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/screen/web/static/screen.css +0 -0
  58. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/screen/web/static/socket.io.min.js +0 -0
  59. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/screen/web/templates/index.html +0 -0
  60. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/tts/__init__.py +0 -0
  61. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/tts/cacher.py +0 -0
  62. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/tts/elevenlabs.py +0 -0
  63. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/tts/google.py +0 -0
  64. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/tts/naoqi.py +0 -0
  65. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/tts/null.py +0 -0
  66. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/vector_store/__init__.py +0 -0
  67. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/vector_store/null.py +0 -0
  68. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/providers/vector_store/redis_store.py +0 -0
  69. {nardial-0.4.1 → nardial-0.4.2}/src/nardial/user_model.py +0 -0
  70. {nardial-0.4.1 → nardial-0.4.2}/src/nardial.egg-info/dependency_links.txt +0 -0
  71. {nardial-0.4.1 → nardial-0.4.2}/src/nardial.egg-info/top_level.txt +0 -0
  72. {nardial-0.4.1 → nardial-0.4.2}/tests/test_agenda_items.py +0 -0
  73. {nardial-0.4.1 → nardial-0.4.2}/tests/test_agenda_resolver.py +0 -0
  74. {nardial-0.4.1 → nardial-0.4.2}/tests/test_authoring_factory.py +0 -0
  75. {nardial-0.4.1 → nardial-0.4.2}/tests/test_conversation_state.py +0 -0
  76. {nardial-0.4.1 → nardial-0.4.2}/tests/test_dialog_registry.py +0 -0
  77. {nardial-0.4.1 → nardial-0.4.2}/tests/test_dialog_types.py +0 -0
  78. {nardial-0.4.1 → nardial-0.4.2}/tests/test_eligibility_rules.py +0 -0
  79. {nardial-0.4.1 → nardial-0.4.2}/tests/test_event_bus.py +0 -0
  80. {nardial-0.4.1 → nardial-0.4.2}/tests/test_llm.py +0 -0
  81. {nardial-0.4.1 → nardial-0.4.2}/tests/test_mini_dialogs_extra.py +0 -0
  82. {nardial-0.4.1 → nardial-0.4.2}/tests/test_moves.py +0 -0
  83. {nardial-0.4.1 → nardial-0.4.2}/tests/test_providers.py +0 -0
  84. {nardial-0.4.1 → nardial-0.4.2}/tests/test_session_plan.py +0 -0
  85. {nardial-0.4.1 → nardial-0.4.2}/tests/test_slot_bounds.py +0 -0
  86. {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.1
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.1"
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=10) -> NLUResult:
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
- if self.interaction_conf.signal_listening_behavior:
194
- self.device.signal_listening(start=False)
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