rasa-pro 3.12.0rc1__py3-none-any.whl → 3.12.0rc3__py3-none-any.whl

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.

Potentially problematic release.


This version of rasa-pro might be problematic. Click here for more details.

Files changed (70) hide show
  1. README.md +10 -13
  2. rasa/cli/dialogue_understanding_test.py +5 -8
  3. rasa/cli/llm_fine_tuning.py +47 -12
  4. rasa/cli/project_templates/calm/domain/list_contacts.yml +1 -2
  5. rasa/cli/project_templates/calm/domain/remove_contact.yml +1 -2
  6. rasa/cli/project_templates/calm/domain/shared.yml +1 -4
  7. rasa/core/actions/action_handle_digressions.py +35 -13
  8. rasa/core/channels/voice_stream/asr/asr_event.py +5 -0
  9. rasa/core/channels/voice_stream/audiocodes.py +19 -6
  10. rasa/core/channels/voice_stream/call_state.py +3 -9
  11. rasa/core/channels/voice_stream/genesys.py +40 -55
  12. rasa/core/channels/voice_stream/voice_channel.py +61 -39
  13. rasa/core/policies/flows/flow_executor.py +7 -2
  14. rasa/core/processor.py +0 -1
  15. rasa/core/tracker_store.py +123 -34
  16. rasa/dialogue_understanding/commands/can_not_handle_command.py +1 -1
  17. rasa/dialogue_understanding/commands/cancel_flow_command.py +1 -1
  18. rasa/dialogue_understanding/commands/change_flow_command.py +1 -1
  19. rasa/dialogue_understanding/commands/chit_chat_answer_command.py +1 -1
  20. rasa/dialogue_understanding/commands/clarify_command.py +1 -1
  21. rasa/dialogue_understanding/commands/command_syntax_manager.py +1 -1
  22. rasa/dialogue_understanding/commands/handle_digressions_command.py +1 -7
  23. rasa/dialogue_understanding/commands/human_handoff_command.py +1 -1
  24. rasa/dialogue_understanding/commands/knowledge_answer_command.py +1 -1
  25. rasa/dialogue_understanding/commands/repeat_bot_messages_command.py +1 -1
  26. rasa/dialogue_understanding/commands/set_slot_command.py +2 -1
  27. rasa/dialogue_understanding/commands/skip_question_command.py +1 -1
  28. rasa/dialogue_understanding/commands/start_flow_command.py +3 -1
  29. rasa/dialogue_understanding/commands/utils.py +2 -32
  30. rasa/dialogue_understanding/generator/command_parser.py +41 -0
  31. rasa/dialogue_understanding/generator/constants.py +7 -2
  32. rasa/dialogue_understanding/generator/llm_based_command_generator.py +9 -2
  33. rasa/dialogue_understanding/generator/multi_step/multi_step_llm_command_generator.py +1 -1
  34. rasa/dialogue_understanding/generator/prompt_templates/command_prompt_v2_claude_3_5_sonnet_20240620_template.jinja2 +29 -48
  35. rasa/dialogue_understanding/generator/prompt_templates/command_prompt_v2_fallback_other_models_template.jinja2 +57 -0
  36. rasa/dialogue_understanding/generator/prompt_templates/command_prompt_v2_gpt_4o_2024_11_20_template.jinja2 +23 -50
  37. rasa/dialogue_understanding/generator/single_step/compact_llm_command_generator.py +141 -27
  38. rasa/dialogue_understanding/generator/single_step/single_step_llm_command_generator.py +32 -18
  39. rasa/dialogue_understanding/processor/command_processor.py +43 -23
  40. rasa/dialogue_understanding/stack/utils.py +49 -6
  41. rasa/dialogue_understanding_test/du_test_case.py +30 -10
  42. rasa/dialogue_understanding_test/du_test_result.py +1 -1
  43. rasa/e2e_test/assertions.py +6 -8
  44. rasa/e2e_test/llm_judge_prompts/answer_relevance_prompt_template.jinja2 +5 -1
  45. rasa/e2e_test/llm_judge_prompts/groundedness_prompt_template.jinja2 +4 -0
  46. rasa/engine/language.py +67 -25
  47. rasa/llm_fine_tuning/conversations.py +3 -31
  48. rasa/llm_fine_tuning/llm_data_preparation_module.py +5 -3
  49. rasa/llm_fine_tuning/paraphrasing/rephrase_validator.py +18 -13
  50. rasa/llm_fine_tuning/paraphrasing_module.py +6 -2
  51. rasa/llm_fine_tuning/train_test_split_module.py +27 -27
  52. rasa/llm_fine_tuning/utils.py +7 -0
  53. rasa/shared/constants.py +4 -0
  54. rasa/shared/core/domain.py +2 -0
  55. rasa/shared/core/slots.py +6 -0
  56. rasa/shared/providers/_configs/azure_entra_id_config.py +8 -8
  57. rasa/shared/providers/llm/litellm_router_llm_client.py +1 -0
  58. rasa/shared/providers/llm/openai_llm_client.py +2 -2
  59. rasa/shared/providers/router/_base_litellm_router_client.py +38 -7
  60. rasa/shared/utils/llm.py +69 -10
  61. rasa/telemetry.py +13 -3
  62. rasa/tracing/instrumentation/attribute_extractors.py +2 -5
  63. rasa/validator.py +2 -2
  64. rasa/version.py +1 -1
  65. {rasa_pro-3.12.0rc1.dist-info → rasa_pro-3.12.0rc3.dist-info}/METADATA +12 -14
  66. {rasa_pro-3.12.0rc1.dist-info → rasa_pro-3.12.0rc3.dist-info}/RECORD +69 -68
  67. rasa/dialogue_understanding/generator/prompt_templates/command_prompt_v2_default.jinja2 +0 -68
  68. {rasa_pro-3.12.0rc1.dist-info → rasa_pro-3.12.0rc3.dist-info}/NOTICE +0 -0
  69. {rasa_pro-3.12.0rc1.dist-info → rasa_pro-3.12.0rc3.dist-info}/WHEEL +0 -0
  70. {rasa_pro-3.12.0rc1.dist-info → rasa_pro-3.12.0rc3.dist-info}/entry_points.txt +0 -0
README.md CHANGED
@@ -2,15 +2,14 @@
2
2
 
3
3
  <div align="center">
4
4
 
5
- [![Build Status](https://github.com/RasaHQ/rasa-private/workflows/Continuous%20Integration/badge.svg)](https://github.com/RasaHQ/rasa-private/actions)
6
5
  [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=RasaHQ_rasa&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=RasaHQ_rasa)
7
- [![Documentation Status](https://img.shields.io/badge/docs-stable-brightgreen.svg)](https://rasa.com/docs/rasa-pro/)
6
+ [![Documentation Status](https://img.shields.io/badge/docs-stable-brightgreen.svg)](https://rasa.com/docs/docs/pro/intro)
7
+ ![Python version support](https://img.shields.io/pypi/pyversions/rasa-pro)
8
8
 
9
9
  </div>
10
10
 
11
11
  <hr />
12
12
 
13
-
14
13
  Rasa Pro is a framework for building scalable, dynamic conversational AI assistants that integrate large language models (LLMs) to enable more contextually aware and agentic interactions. Whether you’re new to conversational AI or an experienced developer, Rasa Pro offers enhanced flexibility, control, and performance for mission-critical applications.
15
14
 
16
15
  Building on the foundation of Rasa Open Source, Rasa Pro adds advanced features like CALM (Conversational AI with Language Models) and Dialogue Understanding (DU), which enable developers to shift from traditional intent-driven systems to LLM-based agents. This allows for more robust, responsive interactions that adhere strictly to business logic, while reducing risks like prompt injection and minimizing hallucinations.
@@ -23,19 +22,17 @@ Building on the foundation of Rasa Open Source, Rasa Pro adds advanced features
23
22
  - **Robustness and Control:** Maintain strict adherence to business logic, preventing unwanted behaviors like prompt injection and hallucinations, leading to more reliable responses and secure interactions.
24
23
  - **Built-in Security:** Safeguard sensitive data, control access, and ensure secure deployment, essential for production environments that demand high levels of security and compliance.
25
24
 
25
+ A [free developer license](https://rasa.com/docs/pro/intro/#who-rasa-pro-is-for) is available so you can explore and get to know Rasa Pro. It allows you to take your assistant live in production a limited capacity. A paid license is required for larger-scale production use, but all code is visible and can be customized as needed.
26
26
 
27
+ To get started right now, you can
27
28
 
28
- A [free developer license](https://rasa.com/docs/rasa-pro/developer-edition/) is available so you can explore and get to know Rasa Pro. For small production deployments, the Extended Developer License allows you to take your assistant live in a limited capacity. A paid license is required for larger-scale production use, but all code is visible and can be customized as needed.
29
-
30
- To get started right now, you can
31
-
32
- `pip install rasa-pro`
29
+ `pip install rasa-pro`
33
30
 
34
- Check out our
31
+ Check out our
35
32
 
36
- - [Rasa-pro Quickstart](https://rasa.com/docs/rasa-pro/installation/quickstart/),
37
- - [Conversational AI with Language Models (CALM) conceptual rundown](https://rasa.com/docs/rasa-pro/calm/),
38
- - [Rasa Pro / CALM tutorial](https://rasa.com/docs/rasa-pro/tutorial), and
39
- - [Rasa pro changelog](https://rasa.com/docs/rasa/rasa-pro-changelog/)
33
+ - [Rasa-pro Quickstart](https://rasa.com/docs/learn/quickstart/pro),
34
+ - [Conversational AI with Language Models (CALM) conceptual rundown](https://rasa.com/docs/learn/concepts/calm),
35
+ - [Rasa Pro / CALM tutorial](https://rasa.com/docs/pro/tutorial), and
36
+ - [Rasa pro changelog](https://rasa.com/docs/reference/changelogs/rasa-pro-changelog)
40
37
 
41
38
  for more. Also feel free to reach out to us on the [Rasa forum](https://forum.rasa.com/).
@@ -3,7 +3,7 @@ import asyncio
3
3
  import datetime
4
4
  import importlib
5
5
  import sys
6
- from typing import Any, Dict, List, Optional
6
+ from typing import Any, Dict, List, Optional, Type, cast
7
7
 
8
8
  import structlog
9
9
 
@@ -20,9 +20,7 @@ from rasa.core.exceptions import AgentNotReady
20
20
  from rasa.core.processor import MessageProcessor
21
21
  from rasa.core.utils import AvailableEndpoints
22
22
  from rasa.dialogue_understanding.commands import Command
23
- from rasa.dialogue_understanding.generator import (
24
- LLMBasedCommandGenerator,
25
- )
23
+ from rasa.dialogue_understanding.generator import LLMBasedCommandGenerator
26
24
  from rasa.dialogue_understanding.generator.command_parser import DEFAULT_COMMANDS
27
25
  from rasa.dialogue_understanding_test.command_metric_calculation import (
28
26
  calculate_command_metrics,
@@ -372,18 +370,17 @@ def split_test_results(
372
370
  def _get_llm_command_generator_config(
373
371
  processor: MessageProcessor,
374
372
  ) -> Optional[Dict[str, Any]]:
375
- from rasa.dialogue_understanding.generator.constants import DEFAULT_LLM_CONFIG
376
-
377
373
  train_schema = processor.model_metadata.train_schema
378
374
 
379
375
  for node_name, node in train_schema.nodes.items():
380
376
  if node.matches_type(LLMBasedCommandGenerator, include_subtypes=True):
381
377
  # Configurations can reference model groups defined in the endpoints.yml
382
- resolved_config = resolve_model_client_config(
378
+ resolved_llm_config = resolve_model_client_config(
383
379
  node.config.get(LLM_CONFIG_KEY, {}), node_name
384
380
  )
381
+ llm_command_generator = cast(Type[LLMBasedCommandGenerator], node.uses)
385
382
  return combine_custom_and_default_config(
386
- resolved_config, DEFAULT_LLM_CONFIG
383
+ resolved_llm_config, llm_command_generator.get_default_llm_config()
387
384
  )
388
385
 
389
386
  return None
@@ -1,7 +1,7 @@
1
1
  import argparse
2
2
  import asyncio
3
3
  import sys
4
- from typing import Any, Dict, List
4
+ from typing import Any, Dict, List, Type, cast
5
5
 
6
6
  import structlog
7
7
 
@@ -22,7 +22,12 @@ from rasa.cli.e2e_test import (
22
22
  )
23
23
  from rasa.core.exceptions import AgentNotReady
24
24
  from rasa.core.utils import AvailableEndpoints
25
- from rasa.dialogue_understanding.generator import SingleStepLLMCommandGenerator
25
+ from rasa.dialogue_understanding.generator.llm_based_command_generator import (
26
+ LLMBasedCommandGenerator,
27
+ )
28
+ from rasa.dialogue_understanding.generator.multi_step.multi_step_llm_command_generator import ( # noqa: E501
29
+ MultiStepLLMCommandGenerator,
30
+ )
26
31
  from rasa.e2e_test.e2e_test_runner import E2ETestRunner
27
32
  from rasa.llm_fine_tuning.annotation_module import annotate_e2e_tests
28
33
  from rasa.llm_fine_tuning.llm_data_preparation_module import convert_to_fine_tuning_data
@@ -112,7 +117,6 @@ def create_llm_finetune_data_preparation_subparser(
112
117
  help_text="Configuration file for the model server and the connectors as a "
113
118
  "yml file.",
114
119
  )
115
-
116
120
  return data_preparation_subparser
117
121
 
118
122
 
@@ -205,6 +209,9 @@ def prepare_llm_fine_tuning_data(args: argparse.Namespace) -> None:
205
209
 
206
210
  flows = asyncio.run(e2e_test_runner.agent.processor.get_flows())
207
211
  llm_command_generator_config = _get_llm_command_generator_config(e2e_test_runner)
212
+ llm_command_generator: Type[LLMBasedCommandGenerator] = _get_llm_command_generator(
213
+ e2e_test_runner
214
+ )
208
215
 
209
216
  # set up storage context
210
217
  storage_context = create_storage_context(StorageType.FILE, output_dir)
@@ -235,6 +242,7 @@ def prepare_llm_fine_tuning_data(args: argparse.Namespace) -> None:
235
242
  rephrase_config,
236
243
  args.num_rephrases,
237
244
  flows,
245
+ llm_command_generator,
238
246
  llm_command_generator_config,
239
247
  storage_context,
240
248
  )
@@ -271,30 +279,57 @@ def prepare_llm_fine_tuning_data(args: argparse.Namespace) -> None:
271
279
  write_statistics(statistics, output_dir)
272
280
 
273
281
  rasa.shared.utils.cli.print_success(
274
- f"Data and intermediate results are written " f"to '{output_dir}'."
282
+ f"Data and intermediate results are written to '{output_dir}'."
275
283
  )
276
284
 
277
285
 
278
286
  def _get_llm_command_generator_config(e2e_test_runner: E2ETestRunner) -> Dict[str, Any]:
279
- from rasa.dialogue_understanding.generator.constants import DEFAULT_LLM_CONFIG
280
-
281
287
  train_schema = e2e_test_runner.agent.processor.model_metadata.train_schema # type: ignore
282
288
 
283
289
  for node_name, node in train_schema.nodes.items():
284
- if node.matches_type(SingleStepLLMCommandGenerator, include_subtypes=True):
290
+ if node.matches_type(
291
+ LLMBasedCommandGenerator, include_subtypes=True
292
+ ) and not node.matches_type(
293
+ MultiStepLLMCommandGenerator, include_subtypes=True
294
+ ):
285
295
  # Configurations can reference model groups defined in the endpoints.yml
286
- resolved_config = resolve_model_client_config(
296
+ resolved_llm_config = resolve_model_client_config(
287
297
  node.config.get(LLM_CONFIG_KEY, {}), node_name
288
298
  )
299
+ llm_command_generator = cast(Type[LLMBasedCommandGenerator], node.uses)
289
300
  return combine_custom_and_default_config(
290
- resolved_config, DEFAULT_LLM_CONFIG
301
+ resolved_llm_config, llm_command_generator.get_default_llm_config()
291
302
  )
292
303
 
293
304
  rasa.shared.utils.cli.print_error(
294
305
  "The provided model is not trained using 'SingleStepLLMCommandGenerator' or "
295
- "its subclasses. Without it, no data for fine-tuning can be generated. To "
296
- "resolve this, please include 'SingleStepLLMCommandGenerator' or its subclass "
297
- "in your config and train your model."
306
+ "'CompactLLMCommandGenerator' or its subclasses. Without it, no data for "
307
+ "fine-tuning can be generated. To resolve this, please include "
308
+ "'SingleStepLLMCommandGenerator' or 'CompactLLMCommandGenerator' or its "
309
+ "subclasses in your config and train your model."
310
+ )
311
+ sys.exit(1)
312
+
313
+
314
+ def _get_llm_command_generator(
315
+ e2e_test_runner: E2ETestRunner,
316
+ ) -> Type[LLMBasedCommandGenerator]:
317
+ train_schema = e2e_test_runner.agent.processor.model_metadata.train_schema # type: ignore
318
+
319
+ for _, node in train_schema.nodes.items():
320
+ if node.matches_type(
321
+ LLMBasedCommandGenerator, include_subtypes=True
322
+ ) and not node.matches_type(
323
+ MultiStepLLMCommandGenerator, include_subtypes=True
324
+ ):
325
+ return cast(Type[LLMBasedCommandGenerator], node.uses)
326
+
327
+ rasa.shared.utils.cli.print_error(
328
+ "The provided model is not trained using 'SingleStepLLMCommandGenerator' or "
329
+ "'CompactLLMCommandGenerator' or its subclasses. Without it, no data for "
330
+ "fine-tuning can be generated. To resolve this, please include "
331
+ "'SingleStepLLMCommandGenerator' or 'CompactLLMCommandGenerator' or its "
332
+ "subclasses in your config and train your model."
298
333
  )
299
334
  sys.exit(1)
300
335
 
@@ -7,8 +7,7 @@ slots:
7
7
  contacts_list:
8
8
  type: text
9
9
  mappings:
10
- - type: custom
11
- action: list_contacts
10
+ - type: controlled
12
11
 
13
12
  responses:
14
13
  utter_no_contacts:
@@ -7,8 +7,7 @@ slots:
7
7
  remove_contact_name:
8
8
  type: text
9
9
  mappings:
10
- - type: custom
11
- action: remove_contact
10
+ - type: controlled
12
11
  remove_contact_handle:
13
12
  type: text
14
13
  mappings:
@@ -4,7 +4,4 @@ slots:
4
4
  return_value:
5
5
  type: any
6
6
  mappings:
7
- - type: custom
8
- action: add_contact
9
- - type: custom
10
- action: remove_contact
7
+ - type: controlled
@@ -18,6 +18,10 @@ from rasa.dialogue_understanding.stack.frames.flow_stack_frame import (
18
18
  FlowStackFrameType,
19
19
  UserFlowStackFrame,
20
20
  )
21
+ from rasa.dialogue_understanding.stack.utils import (
22
+ remove_digression_from_stack,
23
+ user_flows_on_the_stack,
24
+ )
21
25
  from rasa.shared.core.constants import (
22
26
  ACTION_BLOCK_DIGRESSION,
23
27
  ACTION_CONTINUE_DIGRESSION,
@@ -55,16 +59,24 @@ class ActionBlockDigressions(Action):
55
59
  frame_type = FlowStackFrameType.REGULAR
56
60
 
57
61
  stack = tracker.stack
58
- stack.push(
59
- UserFlowStackFrame(flow_id=blocked_flow_id, frame_type=frame_type), 0
60
- )
61
- stack.push(
62
- ContinueInterruptedPatternFlowStackFrame(
63
- previous_flow_name=blocked_flow_id
64
- ),
65
- 1,
66
- )
67
- events = tracker.create_stack_updated_events(stack)
62
+
63
+ if blocked_flow_id in user_flows_on_the_stack(stack):
64
+ structlogger.debug(
65
+ "action_block_digressions.already_blocked_flow",
66
+ blocked_flow_id=blocked_flow_id,
67
+ )
68
+ events = []
69
+ else:
70
+ stack.push(
71
+ UserFlowStackFrame(flow_id=blocked_flow_id, frame_type=frame_type), 0
72
+ )
73
+ stack.push(
74
+ ContinueInterruptedPatternFlowStackFrame(
75
+ previous_flow_name=blocked_flow_id
76
+ ),
77
+ 1,
78
+ )
79
+ events = tracker.create_stack_updated_events(stack)
68
80
 
69
81
  utterance = "utter_block_digressions"
70
82
  message = await nlg.generate(
@@ -109,10 +121,20 @@ class ActionContinueDigression(Action):
109
121
  if not isinstance(top_frame, HandleDigressionsPatternFlowStackFrame):
110
122
  return []
111
123
 
112
- blocked_flow_id = top_frame.interrupting_flow_id
113
- frame_type = FlowStackFrameType.INTERRUPT
124
+ interrupting_flow_id = top_frame.interrupting_flow_id
114
125
  stack = tracker.stack
115
- stack.push(UserFlowStackFrame(flow_id=blocked_flow_id, frame_type=frame_type))
126
+
127
+ if interrupting_flow_id in user_flows_on_the_stack(stack):
128
+ structlogger.debug(
129
+ "action_continue_digression.interrupting_flow_id_already_on_the_stack",
130
+ interrupting_flow_id=interrupting_flow_id,
131
+ )
132
+ stack = remove_digression_from_stack(stack, interrupting_flow_id)
133
+
134
+ frame_type = FlowStackFrameType.INTERRUPT
135
+ stack.push(
136
+ UserFlowStackFrame(flow_id=interrupting_flow_id, frame_type=frame_type)
137
+ )
116
138
 
117
139
  events = [
118
140
  FlowInterrupted(
@@ -16,3 +16,8 @@ class NewTranscript(ASREvent):
16
16
  @dataclass
17
17
  class UserIsSpeaking(ASREvent):
18
18
  pass
19
+
20
+
21
+ @dataclass
22
+ class UserSilence(ASREvent):
23
+ pass
@@ -46,6 +46,19 @@ class AudiocodesVoiceOutputChannel(VoiceOutputChannel):
46
46
  def name(cls) -> str:
47
47
  return "ac_voice"
48
48
 
49
+ def _ensure_stream_id(self) -> None:
50
+ """Audiocodes requires a stream ID with playStream messages."""
51
+ if "stream_id" not in call_state.channel_data:
52
+ call_state.channel_data["stream_id"] = 0
53
+
54
+ def _increment_stream_id(self) -> None:
55
+ self._ensure_stream_id()
56
+ call_state.channel_data["stream_id"] += 1
57
+
58
+ def _get_stream_id(self) -> str:
59
+ self._ensure_stream_id()
60
+ return str(call_state.channel_data["stream_id"])
61
+
49
62
  def rasa_audio_bytes_to_channel_bytes(
50
63
  self, rasa_audio_bytes: RasaAudioBytes
51
64
  ) -> bytes:
@@ -55,7 +68,7 @@ class AudiocodesVoiceOutputChannel(VoiceOutputChannel):
55
68
  media_message = json.dumps(
56
69
  {
57
70
  "type": "playStream.chunk",
58
- "streamId": str(call_state.stream_id),
71
+ "streamId": self._get_stream_id(),
59
72
  "audioChunk": channel_bytes.decode("utf-8"),
60
73
  }
61
74
  )
@@ -63,14 +76,14 @@ class AudiocodesVoiceOutputChannel(VoiceOutputChannel):
63
76
 
64
77
  async def send_start_marker(self, recipient_id: str) -> None:
65
78
  """Send playStream.start before first audio chunk."""
66
- call_state.stream_id += 1 # type: ignore[attr-defined]
79
+ self._increment_stream_id()
67
80
  media_message = json.dumps(
68
81
  {
69
82
  "type": "playStream.start",
70
- "streamId": str(call_state.stream_id),
83
+ "streamId": self._get_stream_id(),
71
84
  }
72
85
  )
73
- logger.debug("Sending start marker", stream_id=call_state.stream_id)
86
+ logger.debug("Sending start marker", stream_id=self._get_stream_id())
74
87
  await self.voice_websocket.send(media_message)
75
88
 
76
89
  async def send_intermediate_marker(self, recipient_id: str) -> None:
@@ -82,10 +95,10 @@ class AudiocodesVoiceOutputChannel(VoiceOutputChannel):
82
95
  media_message = json.dumps(
83
96
  {
84
97
  "type": "playStream.stop",
85
- "streamId": str(call_state.stream_id),
98
+ "streamId": self._get_stream_id(),
86
99
  }
87
100
  )
88
- logger.debug("Sending end marker", stream_id=call_state.stream_id)
101
+ logger.debug("Sending end marker", stream_id=self._get_stream_id())
89
102
  await self.voice_websocket.send(media_message)
90
103
 
91
104
 
@@ -1,7 +1,7 @@
1
1
  import asyncio
2
2
  from contextvars import ContextVar
3
3
  from dataclasses import dataclass, field
4
- from typing import Optional
4
+ from typing import Any, Dict, Optional
5
5
 
6
6
  from werkzeug.local import LocalProxy
7
7
 
@@ -19,14 +19,8 @@ class CallState:
19
19
  should_hangup: bool = False
20
20
  connection_failed: bool = False
21
21
 
22
- # Genesys requires the server and client each maintain a
23
- # monotonically increasing message sequence number.
24
- client_sequence_number: int = 0
25
- server_sequence_number: int = 0
26
- audio_buffer: bytearray = field(default_factory=bytearray)
27
-
28
- # Audiocodes requires a stream ID at start and end of stream
29
- stream_id: int = 0
22
+ # Generic field for channel-specific state data
23
+ channel_data: Dict[str, Any] = field(default_factory=dict)
30
24
 
31
25
 
32
26
  _call_state: ContextVar[CallState] = ContextVar("call_state")
@@ -27,8 +27,23 @@ from rasa.core.channels.voice_stream.voice_channel import (
27
27
  VoiceOutputChannel,
28
28
  )
29
29
 
30
- # Not mentioned in the documentation but observed in Geneys's example
31
- # https://github.com/GenesysCloudBlueprints/audioconnector-server-reference-implementation
30
+ """
31
+ Genesys throws a rate limit error with too many audio messages.
32
+ To avoid this, we buffer the audio messages and send them in chunks.
33
+
34
+ - global.inbound.binary.average.rate.per.second: 5
35
+ The allowed average rate per second of inbound binary data
36
+
37
+ - global.inbound.binary.max: 25
38
+ The maximum number of inbound binary data messages
39
+ that can be sent instantaneously
40
+
41
+ https://developer.genesys.cloud/organization/organization/limits#audiohook
42
+
43
+ The maximum binary message size is not mentioned
44
+ in the documentation but observed in their example app
45
+ https://github.com/GenesysCloudBlueprints/audioconnector-server-reference-implementation
46
+ """
32
47
  MAXIMUM_BINARY_MESSAGE_SIZE = 64000 # 64KB
33
48
  logger = structlog.get_logger(__name__)
34
49
 
@@ -56,52 +71,7 @@ class GenesysOutputChannel(VoiceOutputChannel):
56
71
  async def send_audio_bytes(
57
72
  self, recipient_id: str, audio_bytes: RasaAudioBytes
58
73
  ) -> None:
59
- """
60
- Send audio bytes to the recipient with buffering.
61
-
62
- Genesys throws a rate limit error with too many audio messages.
63
- To avoid this, we buffer the audio messages and send them in chunks.
64
-
65
- - global.inbound.binary.average.rate.per.second: 5
66
- The allowed average rate per second of inbound binary data
67
-
68
- - global.inbound.binary.max: 25
69
- The maximum number of inbound binary data messages
70
- that can be sent instantaneously
71
-
72
- https://developer.genesys.cloud/organization/organization/limits#audiohook
73
- """
74
- call_state.audio_buffer.extend(audio_bytes)
75
-
76
- # If we receive a non-standard chunk size, assume it's the end of a sequence
77
- # or buffer is more than 32KB (this is half of genesys's max audio message size)
78
- if len(audio_bytes) != 1024 or len(call_state.audio_buffer) >= (
79
- MAXIMUM_BINARY_MESSAGE_SIZE / 2
80
- ):
81
- # TODO: we should send the buffer when we receive a synthesis complete event
82
- # from TTS. This will ensure that the last audio chunk is always sent.
83
- await self._send_audio_buffer(self.voice_websocket)
84
-
85
- async def _send_audio_buffer(self, ws: Websocket) -> None:
86
- """Send the audio buffer to the recipient if it's not empty."""
87
- if call_state.audio_buffer:
88
- buffer_bytes = bytes(call_state.audio_buffer)
89
- await self._send_bytes_to_ws(ws, buffer_bytes)
90
- call_state.audio_buffer.clear()
91
-
92
- async def _send_bytes_to_ws(self, ws: Websocket, data: bytes) -> None:
93
- """Send audio bytes to the recipient as a binary websocket message."""
94
- if len(data) <= MAXIMUM_BINARY_MESSAGE_SIZE:
95
- await self.voice_websocket.send(data)
96
- else:
97
- # split the audio into chunks
98
- current_position = 0
99
- while current_position < len(data):
100
- end_position = min(
101
- current_position + MAXIMUM_BINARY_MESSAGE_SIZE, len(data)
102
- )
103
- await self.voice_websocket.send(data[current_position:end_position])
104
- current_position = end_position
74
+ await self.voice_websocket.send(audio_bytes)
105
75
 
106
76
  async def send_marker_message(self, recipient_id: str) -> None:
107
77
  """
@@ -119,6 +89,17 @@ class GenesysInputChannel(VoiceInputChannel):
119
89
  def __init__(self, *args: Any, **kwargs: Any) -> None:
120
90
  super().__init__(*args, **kwargs)
121
91
 
92
+ def _ensure_channel_data_initialized(self) -> None:
93
+ """Initialize Genesys-specific channel data if not already present.
94
+
95
+ Genesys requires the server and client each maintain a
96
+ monotonically increasing message sequence number.
97
+ """
98
+ if "server_sequence_number" not in call_state.channel_data:
99
+ call_state.channel_data["server_sequence_number"] = 0
100
+ if "client_sequence_number" not in call_state.channel_data:
101
+ call_state.channel_data["client_sequence_number"] = 0
102
+
122
103
  def _get_next_sequence(self) -> int:
123
104
  """
124
105
  Get the next message sequence number
@@ -128,23 +109,26 @@ class GenesysInputChannel(VoiceInputChannel):
128
109
  Genesys requires the server and client each maintain a
129
110
  monotonically increasing message sequence number.
130
111
  """
131
- cs = call_state
132
- cs.server_sequence_number += 1 # type: ignore[attr-defined]
133
- return cs.server_sequence_number
112
+ self._ensure_channel_data_initialized()
113
+ call_state.channel_data["server_sequence_number"] += 1
114
+ return call_state.channel_data["server_sequence_number"]
134
115
 
135
116
  def _get_last_client_sequence(self) -> int:
136
117
  """Get the last client(Genesys) sequence number."""
137
- return call_state.client_sequence_number
118
+ self._ensure_channel_data_initialized()
119
+ return call_state.channel_data["client_sequence_number"]
138
120
 
139
121
  def _update_client_sequence(self, seq: int) -> None:
140
122
  """Update the client(Genesys) sequence number."""
141
- if seq - call_state.client_sequence_number != 1:
123
+ self._ensure_channel_data_initialized()
124
+
125
+ if seq - call_state.channel_data["client_sequence_number"] != 1:
142
126
  logger.warning(
143
127
  "genesys.update_client_sequence.sequence_gap",
144
128
  received_seq=seq,
145
- last_seq=call_state.client_sequence_number,
129
+ last_seq=call_state.channel_data["client_sequence_number"],
146
130
  )
147
- call_state.client_sequence_number = seq # type: ignore[attr-defined]
131
+ call_state.channel_data["client_sequence_number"] = seq
148
132
 
149
133
  def channel_bytes_to_rasa_audio_bytes(self, input_bytes: bytes) -> RasaAudioBytes:
150
134
  return RasaAudioBytes(input_bytes)
@@ -211,6 +195,7 @@ class GenesysInputChannel(VoiceInputChannel):
211
195
  voice_websocket,
212
196
  tts_engine,
213
197
  self.tts_cache,
198
+ min_buffer_size=MAXIMUM_BINARY_MESSAGE_SIZE // 2,
214
199
  )
215
200
 
216
201
  async def handle_open(self, ws: Websocket, message: dict) -> CallParameters: