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.
- README.md +10 -13
- rasa/cli/dialogue_understanding_test.py +5 -8
- rasa/cli/llm_fine_tuning.py +47 -12
- rasa/cli/project_templates/calm/domain/list_contacts.yml +1 -2
- rasa/cli/project_templates/calm/domain/remove_contact.yml +1 -2
- rasa/cli/project_templates/calm/domain/shared.yml +1 -4
- rasa/core/actions/action_handle_digressions.py +35 -13
- rasa/core/channels/voice_stream/asr/asr_event.py +5 -0
- rasa/core/channels/voice_stream/audiocodes.py +19 -6
- rasa/core/channels/voice_stream/call_state.py +3 -9
- rasa/core/channels/voice_stream/genesys.py +40 -55
- rasa/core/channels/voice_stream/voice_channel.py +61 -39
- rasa/core/policies/flows/flow_executor.py +7 -2
- rasa/core/processor.py +0 -1
- rasa/core/tracker_store.py +123 -34
- rasa/dialogue_understanding/commands/can_not_handle_command.py +1 -1
- rasa/dialogue_understanding/commands/cancel_flow_command.py +1 -1
- rasa/dialogue_understanding/commands/change_flow_command.py +1 -1
- rasa/dialogue_understanding/commands/chit_chat_answer_command.py +1 -1
- rasa/dialogue_understanding/commands/clarify_command.py +1 -1
- rasa/dialogue_understanding/commands/command_syntax_manager.py +1 -1
- rasa/dialogue_understanding/commands/handle_digressions_command.py +1 -7
- rasa/dialogue_understanding/commands/human_handoff_command.py +1 -1
- rasa/dialogue_understanding/commands/knowledge_answer_command.py +1 -1
- rasa/dialogue_understanding/commands/repeat_bot_messages_command.py +1 -1
- rasa/dialogue_understanding/commands/set_slot_command.py +2 -1
- rasa/dialogue_understanding/commands/skip_question_command.py +1 -1
- rasa/dialogue_understanding/commands/start_flow_command.py +3 -1
- rasa/dialogue_understanding/commands/utils.py +2 -32
- rasa/dialogue_understanding/generator/command_parser.py +41 -0
- rasa/dialogue_understanding/generator/constants.py +7 -2
- rasa/dialogue_understanding/generator/llm_based_command_generator.py +9 -2
- rasa/dialogue_understanding/generator/multi_step/multi_step_llm_command_generator.py +1 -1
- rasa/dialogue_understanding/generator/prompt_templates/command_prompt_v2_claude_3_5_sonnet_20240620_template.jinja2 +29 -48
- rasa/dialogue_understanding/generator/prompt_templates/command_prompt_v2_fallback_other_models_template.jinja2 +57 -0
- rasa/dialogue_understanding/generator/prompt_templates/command_prompt_v2_gpt_4o_2024_11_20_template.jinja2 +23 -50
- rasa/dialogue_understanding/generator/single_step/compact_llm_command_generator.py +141 -27
- rasa/dialogue_understanding/generator/single_step/single_step_llm_command_generator.py +32 -18
- rasa/dialogue_understanding/processor/command_processor.py +43 -23
- rasa/dialogue_understanding/stack/utils.py +49 -6
- rasa/dialogue_understanding_test/du_test_case.py +30 -10
- rasa/dialogue_understanding_test/du_test_result.py +1 -1
- rasa/e2e_test/assertions.py +6 -8
- rasa/e2e_test/llm_judge_prompts/answer_relevance_prompt_template.jinja2 +5 -1
- rasa/e2e_test/llm_judge_prompts/groundedness_prompt_template.jinja2 +4 -0
- rasa/engine/language.py +67 -25
- rasa/llm_fine_tuning/conversations.py +3 -31
- rasa/llm_fine_tuning/llm_data_preparation_module.py +5 -3
- rasa/llm_fine_tuning/paraphrasing/rephrase_validator.py +18 -13
- rasa/llm_fine_tuning/paraphrasing_module.py +6 -2
- rasa/llm_fine_tuning/train_test_split_module.py +27 -27
- rasa/llm_fine_tuning/utils.py +7 -0
- rasa/shared/constants.py +4 -0
- rasa/shared/core/domain.py +2 -0
- rasa/shared/core/slots.py +6 -0
- rasa/shared/providers/_configs/azure_entra_id_config.py +8 -8
- rasa/shared/providers/llm/litellm_router_llm_client.py +1 -0
- rasa/shared/providers/llm/openai_llm_client.py +2 -2
- rasa/shared/providers/router/_base_litellm_router_client.py +38 -7
- rasa/shared/utils/llm.py +69 -10
- rasa/telemetry.py +13 -3
- rasa/tracing/instrumentation/attribute_extractors.py +2 -5
- rasa/validator.py +2 -2
- rasa/version.py +1 -1
- {rasa_pro-3.12.0rc1.dist-info → rasa_pro-3.12.0rc3.dist-info}/METADATA +12 -14
- {rasa_pro-3.12.0rc1.dist-info → rasa_pro-3.12.0rc3.dist-info}/RECORD +69 -68
- rasa/dialogue_understanding/generator/prompt_templates/command_prompt_v2_default.jinja2 +0 -68
- {rasa_pro-3.12.0rc1.dist-info → rasa_pro-3.12.0rc3.dist-info}/NOTICE +0 -0
- {rasa_pro-3.12.0rc1.dist-info → rasa_pro-3.12.0rc3.dist-info}/WHEEL +0 -0
- {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
|
-
[](https://github.com/RasaHQ/rasa-private/actions)
|
|
6
5
|
[](https://sonarcloud.io/summary/new_code?id=RasaHQ_rasa)
|
|
7
|
-
[](https://rasa.com/docs/
|
|
6
|
+
[](https://rasa.com/docs/docs/pro/intro)
|
|
7
|
+

|
|
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
|
-
|
|
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/
|
|
37
|
-
- [Conversational AI with Language Models (CALM) conceptual rundown](https://rasa.com/docs/
|
|
38
|
-
- [Rasa Pro / CALM tutorial](https://rasa.com/docs/
|
|
39
|
-
- [Rasa pro changelog](https://rasa.com/docs/
|
|
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
|
-
|
|
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
|
-
|
|
383
|
+
resolved_llm_config, llm_command_generator.get_default_llm_config()
|
|
387
384
|
)
|
|
388
385
|
|
|
389
386
|
return None
|
rasa/cli/llm_fine_tuning.py
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
296
|
-
"resolve this, please include
|
|
297
|
-
"
|
|
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
|
|
|
@@ -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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
113
|
-
frame_type = FlowStackFrameType.INTERRUPT
|
|
124
|
+
interrupting_flow_id = top_frame.interrupting_flow_id
|
|
114
125
|
stack = tracker.stack
|
|
115
|
-
|
|
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(
|
|
@@ -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":
|
|
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
|
-
|
|
79
|
+
self._increment_stream_id()
|
|
67
80
|
media_message = json.dumps(
|
|
68
81
|
{
|
|
69
82
|
"type": "playStream.start",
|
|
70
|
-
"streamId":
|
|
83
|
+
"streamId": self._get_stream_id(),
|
|
71
84
|
}
|
|
72
85
|
)
|
|
73
|
-
logger.debug("Sending start marker", 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":
|
|
98
|
+
"streamId": self._get_stream_id(),
|
|
86
99
|
}
|
|
87
100
|
)
|
|
88
|
-
logger.debug("Sending end marker", 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
|
-
#
|
|
23
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|