rasa-pro 3.12.0.dev10__py3-none-any.whl → 3.12.0.dev11__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.
- rasa/cli/inspect.py +20 -1
- rasa/cli/shell.py +3 -3
- rasa/core/actions/action.py +5 -6
- rasa/core/actions/forms.py +6 -3
- rasa/core/channels/__init__.py +2 -0
- rasa/core/channels/voice_stream/browser_audio.py +1 -0
- rasa/core/channels/voice_stream/call_state.py +7 -1
- rasa/core/channels/voice_stream/genesys.py +331 -0
- rasa/core/channels/voice_stream/tts/cartesia.py +16 -3
- rasa/core/channels/voice_stream/twilio_media_streams.py +2 -1
- rasa/core/channels/voice_stream/voice_channel.py +2 -1
- rasa/core/policies/flows/flow_executor.py +3 -41
- rasa/core/run.py +4 -3
- rasa/dialogue_understanding/generator/command_generator.py +104 -1
- rasa/dialogue_understanding/generator/llm_based_command_generator.py +40 -6
- rasa/dialogue_understanding/generator/multi_step/multi_step_llm_command_generator.py +1 -1
- rasa/dialogue_understanding/generator/nlu_command_adapter.py +41 -2
- rasa/dialogue_understanding/generator/single_step/single_step_llm_command_generator.py +1 -1
- rasa/dialogue_understanding/generator/utils.py +32 -1
- rasa/dialogue_understanding/processor/command_processor.py +10 -12
- rasa/dialogue_understanding_test/README.md +50 -0
- rasa/dialogue_understanding_test/test_case_simulation/test_case_tracker_simulator.py +3 -3
- rasa/model_service.py +4 -0
- rasa/model_training.py +24 -27
- rasa/shared/core/constants.py +6 -2
- rasa/shared/core/domain.py +11 -20
- rasa/shared/core/slot_mappings.py +143 -107
- rasa/shared/core/slots.py +4 -2
- rasa/telemetry.py +43 -13
- rasa/utils/common.py +0 -1
- rasa/validator.py +31 -74
- rasa/version.py +1 -1
- {rasa_pro-3.12.0.dev10.dist-info → rasa_pro-3.12.0.dev11.dist-info}/METADATA +1 -1
- {rasa_pro-3.12.0.dev10.dist-info → rasa_pro-3.12.0.dev11.dist-info}/RECORD +37 -36
- {rasa_pro-3.12.0.dev10.dist-info → rasa_pro-3.12.0.dev11.dist-info}/NOTICE +0 -0
- {rasa_pro-3.12.0.dev10.dist-info → rasa_pro-3.12.0.dev11.dist-info}/WHEEL +0 -0
- {rasa_pro-3.12.0.dev10.dist-info → rasa_pro-3.12.0.dev11.dist-info}/entry_points.txt +0 -0
rasa/cli/inspect.py
CHANGED
|
@@ -9,6 +9,10 @@ from rasa import telemetry
|
|
|
9
9
|
from rasa.cli import SubParsersAction
|
|
10
10
|
from rasa.cli.arguments import shell as arguments
|
|
11
11
|
from rasa.core import constants
|
|
12
|
+
from rasa.engine.storage.local_model_storage import LocalModelStorage
|
|
13
|
+
from rasa.exceptions import ModelNotFound
|
|
14
|
+
from rasa.model import get_local_model
|
|
15
|
+
from rasa.shared.utils.cli import print_error
|
|
12
16
|
from rasa.utils.cli import remove_argument_from_parser
|
|
13
17
|
|
|
14
18
|
|
|
@@ -55,6 +59,8 @@ async def open_inspector_in_browser(server_url: Text, voice: bool = False) -> No
|
|
|
55
59
|
def inspect(args: argparse.Namespace) -> None:
|
|
56
60
|
"""Inspect the bot using the most recent model."""
|
|
57
61
|
import rasa.cli.run
|
|
62
|
+
from rasa.cli.utils import get_validated_path
|
|
63
|
+
from rasa.shared.constants import DEFAULT_MODELS_PATH
|
|
58
64
|
|
|
59
65
|
async def after_start_hook_open_inspector(_: Sanic, __: AbstractEventLoop) -> None:
|
|
60
66
|
"""Hook to open the browser on server start."""
|
|
@@ -71,5 +77,18 @@ def inspect(args: argparse.Namespace) -> None:
|
|
|
71
77
|
args.credentials = None
|
|
72
78
|
args.server_listeners = [(after_start_hook_open_inspector, "after_server_start")]
|
|
73
79
|
|
|
74
|
-
|
|
80
|
+
model = get_validated_path(args.model, "model", DEFAULT_MODELS_PATH)
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
model = get_local_model(model)
|
|
84
|
+
except ModelNotFound:
|
|
85
|
+
print_error(
|
|
86
|
+
"No model found. Train a model before running the "
|
|
87
|
+
"server using `rasa train`."
|
|
88
|
+
)
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
metadata = LocalModelStorage.metadata_from_archive(model)
|
|
92
|
+
|
|
93
|
+
telemetry.track_inspect_started(args.connector, metadata.assistant_id)
|
|
75
94
|
rasa.cli.run.run(args)
|
rasa/cli/shell.py
CHANGED
|
@@ -95,7 +95,7 @@ def shell_nlu(args: argparse.Namespace) -> None:
|
|
|
95
95
|
)
|
|
96
96
|
return
|
|
97
97
|
|
|
98
|
-
telemetry.track_shell_started("nlu")
|
|
98
|
+
telemetry.track_shell_started("nlu", metadata.assistant_id)
|
|
99
99
|
rasa.nlu.run.run_cmdline(model)
|
|
100
100
|
|
|
101
101
|
|
|
@@ -129,12 +129,12 @@ def shell(args: argparse.Namespace) -> None:
|
|
|
129
129
|
if metadata.training_type == TrainingType.NLU:
|
|
130
130
|
import rasa.nlu.run
|
|
131
131
|
|
|
132
|
-
telemetry.track_shell_started("nlu")
|
|
132
|
+
telemetry.track_shell_started("nlu", metadata.assistant_id)
|
|
133
133
|
|
|
134
134
|
rasa.nlu.run.run_cmdline(model)
|
|
135
135
|
else:
|
|
136
136
|
import rasa.cli.run
|
|
137
137
|
|
|
138
|
-
telemetry.track_shell_started("rasa")
|
|
138
|
+
telemetry.track_shell_started("rasa", metadata.assistant_id)
|
|
139
139
|
|
|
140
140
|
rasa.cli.run.run(args)
|
rasa/core/actions/action.py
CHANGED
|
@@ -72,7 +72,6 @@ from rasa.shared.core.constants import (
|
|
|
72
72
|
ACTION_UNLIKELY_INTENT_NAME,
|
|
73
73
|
ACTION_VALIDATE_SLOT_MAPPINGS,
|
|
74
74
|
DEFAULT_SLOT_NAMES,
|
|
75
|
-
KEY_MAPPING_TYPE,
|
|
76
75
|
KNOWLEDGE_BASE_SLOT_NAMES,
|
|
77
76
|
REQUESTED_SLOT,
|
|
78
77
|
USER_INTENT_OUT_OF_SCOPE,
|
|
@@ -112,6 +111,7 @@ if TYPE_CHECKING:
|
|
|
112
111
|
from rasa.core.channels.channel import OutputChannel
|
|
113
112
|
from rasa.core.nlg import NaturalLanguageGenerator
|
|
114
113
|
from rasa.shared.core.events import IntentPrediction
|
|
114
|
+
from rasa.shared.core.slot_mappings import SlotMapping
|
|
115
115
|
|
|
116
116
|
logger = logging.getLogger(__name__)
|
|
117
117
|
|
|
@@ -1222,7 +1222,7 @@ class ActionExtractSlots(Action):
|
|
|
1222
1222
|
|
|
1223
1223
|
async def _execute_custom_action(
|
|
1224
1224
|
self,
|
|
1225
|
-
mapping:
|
|
1225
|
+
mapping: "SlotMapping",
|
|
1226
1226
|
executed_custom_actions: Set[Text],
|
|
1227
1227
|
output_channel: "OutputChannel",
|
|
1228
1228
|
nlg: "NaturalLanguageGenerator",
|
|
@@ -1230,7 +1230,7 @@ class ActionExtractSlots(Action):
|
|
|
1230
1230
|
domain: "Domain",
|
|
1231
1231
|
calm_custom_action_names: Optional[Set[str]] = None,
|
|
1232
1232
|
) -> Tuple[List[Event], Set[Text]]:
|
|
1233
|
-
custom_action = mapping.
|
|
1233
|
+
custom_action = mapping.run_action_every_turn
|
|
1234
1234
|
|
|
1235
1235
|
if not custom_action or custom_action in executed_custom_actions:
|
|
1236
1236
|
return [], executed_custom_actions
|
|
@@ -1331,10 +1331,9 @@ class ActionExtractSlots(Action):
|
|
|
1331
1331
|
slot_events.append(SlotSet(slot.name, slot_value))
|
|
1332
1332
|
|
|
1333
1333
|
for mapping in slot.mappings:
|
|
1334
|
-
|
|
1335
|
-
should_fill_custom_slot = mapping_type == SlotMappingType.CUSTOM
|
|
1334
|
+
should_fill_controlled_slot = mapping.type == SlotMappingType.CONTROLLED
|
|
1336
1335
|
|
|
1337
|
-
if
|
|
1336
|
+
if should_fill_controlled_slot:
|
|
1338
1337
|
(
|
|
1339
1338
|
custom_evts,
|
|
1340
1339
|
executed_custom_actions,
|
rasa/core/actions/forms.py
CHANGED
|
@@ -2,7 +2,7 @@ import copy
|
|
|
2
2
|
import itertools
|
|
3
3
|
import json
|
|
4
4
|
import logging
|
|
5
|
-
from typing import Any, Dict, List, Optional, Set, Text, Union
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Text, Union
|
|
6
6
|
|
|
7
7
|
import structlog
|
|
8
8
|
|
|
@@ -35,6 +35,9 @@ from rasa.shared.core.slots import ListSlot
|
|
|
35
35
|
from rasa.shared.core.trackers import DialogueStateTracker
|
|
36
36
|
from rasa.utils.endpoints import EndpointConfig
|
|
37
37
|
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from rasa.shared.core.slot_mappings import SlotMapping
|
|
40
|
+
|
|
38
41
|
logger = logging.getLogger(__name__)
|
|
39
42
|
structlogger = structlog.get_logger()
|
|
40
43
|
|
|
@@ -171,7 +174,7 @@ class FormAction(LoopAction):
|
|
|
171
174
|
return unique_entity_slot_mappings
|
|
172
175
|
|
|
173
176
|
def entity_mapping_is_unique(
|
|
174
|
-
self, slot_mapping:
|
|
177
|
+
self, slot_mapping: "SlotMapping", domain: Domain
|
|
175
178
|
) -> bool:
|
|
176
179
|
"""Verifies if the from_entity mapping is unique."""
|
|
177
180
|
if not self._have_unique_entity_mappings_been_initialized:
|
|
@@ -179,7 +182,7 @@ class FormAction(LoopAction):
|
|
|
179
182
|
self._unique_entity_mappings = self._create_unique_entity_mappings(domain)
|
|
180
183
|
self._have_unique_entity_mappings_been_initialized = True
|
|
181
184
|
|
|
182
|
-
mapping_as_string = json.dumps(slot_mapping, sort_keys=True)
|
|
185
|
+
mapping_as_string = json.dumps(slot_mapping.as_dict(), sort_keys=True)
|
|
183
186
|
return mapping_as_string in self._unique_entity_mappings
|
|
184
187
|
|
|
185
188
|
@staticmethod
|
rasa/core/channels/__init__.py
CHANGED
|
@@ -32,6 +32,7 @@ from rasa.core.channels.vier_cvg import CVGInput
|
|
|
32
32
|
from rasa.core.channels.voice_stream.twilio_media_streams import (
|
|
33
33
|
TwilioMediaStreamsInputChannel,
|
|
34
34
|
)
|
|
35
|
+
from rasa.core.channels.voice_stream.genesys import GenesysInputChannel
|
|
35
36
|
from rasa.core.channels.studio_chat import StudioChatInput
|
|
36
37
|
|
|
37
38
|
input_channel_classes: List[Type[InputChannel]] = [
|
|
@@ -55,6 +56,7 @@ input_channel_classes: List[Type[InputChannel]] = [
|
|
|
55
56
|
JambonzVoiceReadyInput,
|
|
56
57
|
TwilioMediaStreamsInputChannel,
|
|
57
58
|
BrowserAudioInputChannel,
|
|
59
|
+
GenesysInputChannel,
|
|
58
60
|
StudioChatInput,
|
|
59
61
|
]
|
|
60
62
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
from contextvars import ContextVar
|
|
3
|
-
from dataclasses import dataclass
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
4
|
from typing import Optional
|
|
5
5
|
|
|
6
6
|
from werkzeug.local import LocalProxy
|
|
@@ -19,6 +19,12 @@ 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
|
+
|
|
22
28
|
|
|
23
29
|
_call_state: ContextVar[CallState] = ContextVar("call_state")
|
|
24
30
|
call_state = LocalProxy(_call_state)
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
from typing import Any, Awaitable, Callable, Dict, Optional, Text
|
|
4
|
+
|
|
5
|
+
import structlog
|
|
6
|
+
from sanic import ( # type: ignore[attr-defined]
|
|
7
|
+
Blueprint,
|
|
8
|
+
HTTPResponse,
|
|
9
|
+
Request,
|
|
10
|
+
Websocket,
|
|
11
|
+
response,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from rasa.core.channels import UserMessage
|
|
15
|
+
from rasa.core.channels.voice_ready.utils import CallParameters
|
|
16
|
+
from rasa.core.channels.voice_stream.audio_bytes import RasaAudioBytes
|
|
17
|
+
from rasa.core.channels.voice_stream.call_state import (
|
|
18
|
+
call_state,
|
|
19
|
+
)
|
|
20
|
+
from rasa.core.channels.voice_stream.tts.tts_engine import TTSEngine
|
|
21
|
+
from rasa.core.channels.voice_stream.voice_channel import (
|
|
22
|
+
ContinueConversationAction,
|
|
23
|
+
EndConversationAction,
|
|
24
|
+
NewAudioAction,
|
|
25
|
+
VoiceChannelAction,
|
|
26
|
+
VoiceInputChannel,
|
|
27
|
+
VoiceOutputChannel,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Not mentioned in the documentation but observed in Geneys's example
|
|
31
|
+
# https://github.com/GenesysCloudBlueprints/audioconnector-server-reference-implementation
|
|
32
|
+
MAXIMUM_BINARY_MESSAGE_SIZE = 64000 # 64KB
|
|
33
|
+
logger = structlog.get_logger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def map_call_params(data: Dict[Text, Any]) -> CallParameters:
|
|
37
|
+
"""Map the twilio stream parameters to the CallParameters dataclass."""
|
|
38
|
+
parameters = data["parameters"]
|
|
39
|
+
participant = parameters["participant"]
|
|
40
|
+
# sent as {"ani": "tel:+491604697810"}
|
|
41
|
+
ani = participant.get("ani", "")
|
|
42
|
+
user_phone = ani.split(":")[-1] if ani else ""
|
|
43
|
+
|
|
44
|
+
return CallParameters(
|
|
45
|
+
call_id=parameters.get("conversationId", ""),
|
|
46
|
+
user_phone=user_phone,
|
|
47
|
+
bot_phone=participant.get("dnis", ""),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class GenesysOutputChannel(VoiceOutputChannel):
|
|
52
|
+
@classmethod
|
|
53
|
+
def name(cls) -> str:
|
|
54
|
+
return "genesys"
|
|
55
|
+
|
|
56
|
+
async def send_audio_bytes(
|
|
57
|
+
self, recipient_id: str, audio_bytes: RasaAudioBytes
|
|
58
|
+
) -> 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
|
|
105
|
+
|
|
106
|
+
async def send_marker_message(self, recipient_id: str) -> None:
|
|
107
|
+
"""Send a message that marks positions in the audio stream."""
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class GenesysInputChannel(VoiceInputChannel):
|
|
112
|
+
@classmethod
|
|
113
|
+
def name(cls) -> str:
|
|
114
|
+
return "genesys"
|
|
115
|
+
|
|
116
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
117
|
+
super().__init__(*args, **kwargs)
|
|
118
|
+
|
|
119
|
+
def _get_next_sequence(self) -> int:
|
|
120
|
+
"""
|
|
121
|
+
Get the next message sequence number
|
|
122
|
+
Rasa == Server
|
|
123
|
+
Genesys == Client
|
|
124
|
+
|
|
125
|
+
Genesys requires the server and client each maintain a
|
|
126
|
+
monotonically increasing message sequence number.
|
|
127
|
+
"""
|
|
128
|
+
cs = call_state
|
|
129
|
+
cs.server_sequence_number += 1 # type: ignore[attr-defined]
|
|
130
|
+
return cs.server_sequence_number
|
|
131
|
+
|
|
132
|
+
def _get_last_client_sequence(self) -> int:
|
|
133
|
+
"""Get the last client(Genesys) sequence number."""
|
|
134
|
+
return call_state.client_sequence_number
|
|
135
|
+
|
|
136
|
+
def _update_client_sequence(self, seq: int) -> None:
|
|
137
|
+
"""Update the client(Genesys) sequence number."""
|
|
138
|
+
if seq - call_state.client_sequence_number != 1:
|
|
139
|
+
logger.warning(
|
|
140
|
+
"genesys.update_client_sequence.sequence_gap",
|
|
141
|
+
received_seq=seq,
|
|
142
|
+
last_seq=call_state.client_sequence_number,
|
|
143
|
+
)
|
|
144
|
+
call_state.client_sequence_number = seq # type: ignore[attr-defined]
|
|
145
|
+
|
|
146
|
+
def channel_bytes_to_rasa_audio_bytes(self, input_bytes: bytes) -> RasaAudioBytes:
|
|
147
|
+
return RasaAudioBytes(input_bytes)
|
|
148
|
+
|
|
149
|
+
async def collect_call_parameters(
|
|
150
|
+
self, channel_websocket: Websocket
|
|
151
|
+
) -> Optional[CallParameters]:
|
|
152
|
+
"""Call Parameters are collected during the open event."""
|
|
153
|
+
async for message in channel_websocket:
|
|
154
|
+
data = json.loads(message)
|
|
155
|
+
self._update_client_sequence(data["seq"])
|
|
156
|
+
if data.get("type") == "open":
|
|
157
|
+
call_params = await self.handle_open(channel_websocket, data)
|
|
158
|
+
return call_params
|
|
159
|
+
else:
|
|
160
|
+
logger.error("genesys.receive.unexpected_initial_message", message=data)
|
|
161
|
+
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
def map_input_message(
|
|
165
|
+
self,
|
|
166
|
+
message: Any,
|
|
167
|
+
ws: Websocket,
|
|
168
|
+
) -> VoiceChannelAction:
|
|
169
|
+
# if message is binary, it's audio
|
|
170
|
+
if isinstance(message, bytes):
|
|
171
|
+
return NewAudioAction(self.channel_bytes_to_rasa_audio_bytes(message))
|
|
172
|
+
else:
|
|
173
|
+
# process text message
|
|
174
|
+
data = json.loads(message)
|
|
175
|
+
self._update_client_sequence(data["seq"])
|
|
176
|
+
msg_type = data.get("type")
|
|
177
|
+
if msg_type == "close":
|
|
178
|
+
logger.info("genesys.handle_close", message=data)
|
|
179
|
+
self.handle_close(ws, data)
|
|
180
|
+
return EndConversationAction()
|
|
181
|
+
elif msg_type == "ping":
|
|
182
|
+
logger.info("genesys.handle_ping", message=data)
|
|
183
|
+
self.handle_ping(ws, data)
|
|
184
|
+
elif msg_type == "playback_started":
|
|
185
|
+
logger.debug("genesys.handle_playback_started", message=data)
|
|
186
|
+
call_state.is_bot_speaking = True # type: ignore[attr-defined]
|
|
187
|
+
elif msg_type == "playback_completed":
|
|
188
|
+
logger.debug("genesys.handle_playback_completed", message=data)
|
|
189
|
+
call_state.is_bot_speaking = False # type: ignore[attr-defined]
|
|
190
|
+
if call_state.should_hangup:
|
|
191
|
+
logger.info("genesys.hangup")
|
|
192
|
+
self.disconnect(ws, data)
|
|
193
|
+
elif msg_type == "dtmf":
|
|
194
|
+
logger.info("genesys.handle_dtmf", message=data)
|
|
195
|
+
elif msg_type == "error":
|
|
196
|
+
logger.warning("genesys.handle_error", message=data)
|
|
197
|
+
else:
|
|
198
|
+
logger.warning("genesys.map_input_message.unknown_type", message=data)
|
|
199
|
+
|
|
200
|
+
return ContinueConversationAction()
|
|
201
|
+
|
|
202
|
+
def create_output_channel(
|
|
203
|
+
self, voice_websocket: Websocket, tts_engine: TTSEngine
|
|
204
|
+
) -> VoiceOutputChannel:
|
|
205
|
+
return GenesysOutputChannel(
|
|
206
|
+
voice_websocket,
|
|
207
|
+
tts_engine,
|
|
208
|
+
self.tts_cache,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
async def handle_open(self, ws: Websocket, message: dict) -> CallParameters:
|
|
212
|
+
"""Handle initial open transaction from Genesys."""
|
|
213
|
+
call_parameters = map_call_params(message)
|
|
214
|
+
params = message["parameters"]
|
|
215
|
+
media_options = params.get("media", [])
|
|
216
|
+
|
|
217
|
+
# Send opened response
|
|
218
|
+
if media_options:
|
|
219
|
+
logger.info("genesys.handle_open", media_parameter=media_options[0])
|
|
220
|
+
response = {
|
|
221
|
+
"version": "2",
|
|
222
|
+
"type": "opened",
|
|
223
|
+
"seq": self._get_next_sequence(),
|
|
224
|
+
"clientseq": self._get_last_client_sequence(),
|
|
225
|
+
"id": message.get("id"),
|
|
226
|
+
"parameters": {"startPaused": False, "media": [media_options[0]]},
|
|
227
|
+
}
|
|
228
|
+
logger.debug("genesys.handle_open.opened", response=response)
|
|
229
|
+
await ws.send(json.dumps(response))
|
|
230
|
+
else:
|
|
231
|
+
logger.warning(
|
|
232
|
+
"genesys.handle_open.no_media_formats", client_message=message
|
|
233
|
+
)
|
|
234
|
+
return call_parameters
|
|
235
|
+
|
|
236
|
+
def handle_ping(self, ws: Websocket, message: dict) -> None:
|
|
237
|
+
"""Handle ping message from Genesys."""
|
|
238
|
+
response = {
|
|
239
|
+
"version": "2",
|
|
240
|
+
"type": "pong",
|
|
241
|
+
"seq": self._get_next_sequence(),
|
|
242
|
+
"clientseq": message.get("seq"),
|
|
243
|
+
"id": message.get("id"),
|
|
244
|
+
"parameters": {},
|
|
245
|
+
}
|
|
246
|
+
logger.debug("genesys.handle_ping.pong", response=response)
|
|
247
|
+
_schedule_ws_task(ws.send(json.dumps(response)))
|
|
248
|
+
|
|
249
|
+
def handle_close(self, ws: Websocket, message: dict) -> None:
|
|
250
|
+
"""Handle close message from Genesys."""
|
|
251
|
+
response = {
|
|
252
|
+
"version": "2",
|
|
253
|
+
"type": "closed",
|
|
254
|
+
"seq": self._get_next_sequence(),
|
|
255
|
+
"clientseq": self._get_last_client_sequence(),
|
|
256
|
+
"id": message.get("id"),
|
|
257
|
+
"parameters": message.get("parameters", {}),
|
|
258
|
+
}
|
|
259
|
+
logger.debug("genesys.handle_close.closed", response=response)
|
|
260
|
+
|
|
261
|
+
_schedule_ws_task(ws.send(json.dumps(response)))
|
|
262
|
+
_schedule_ws_task(ws.close())
|
|
263
|
+
|
|
264
|
+
def disconnect(self, ws: Websocket, data: dict) -> None:
|
|
265
|
+
"""
|
|
266
|
+
Send disconnect message to Genesys.
|
|
267
|
+
|
|
268
|
+
https://developer.genesys.cloud/devapps/audiohook/protocol-reference#disconnect
|
|
269
|
+
It should be used to hangup the call.
|
|
270
|
+
Genesys will respond with a "close" message to us
|
|
271
|
+
that is handled by the handle_close method.
|
|
272
|
+
"""
|
|
273
|
+
message = {
|
|
274
|
+
"version": "2",
|
|
275
|
+
"type": "disconnect",
|
|
276
|
+
"seq": self._get_next_sequence(),
|
|
277
|
+
"clientseq": self._get_last_client_sequence(),
|
|
278
|
+
"id": data.get("id"),
|
|
279
|
+
"parameters": {
|
|
280
|
+
"reason": "completed",
|
|
281
|
+
# arbitrary values can be sent here
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
logger.debug("genesys.disconnect", message=message)
|
|
285
|
+
_schedule_ws_task(ws.send(json.dumps(message)))
|
|
286
|
+
|
|
287
|
+
def blueprint(
|
|
288
|
+
self, on_new_message: Callable[[UserMessage], Awaitable[Any]]
|
|
289
|
+
) -> Blueprint:
|
|
290
|
+
"""Defines a Sanic blueprint for the voice input channel."""
|
|
291
|
+
blueprint = Blueprint("genesys", __name__)
|
|
292
|
+
|
|
293
|
+
@blueprint.route("/", methods=["GET"])
|
|
294
|
+
async def health(_: Request) -> HTTPResponse:
|
|
295
|
+
return response.json({"status": "ok"})
|
|
296
|
+
|
|
297
|
+
@blueprint.websocket("/websocket") # type: ignore[misc]
|
|
298
|
+
async def receive(request: Request, ws: Websocket) -> None:
|
|
299
|
+
logger.debug(
|
|
300
|
+
"genesys.receive",
|
|
301
|
+
audiohook_session_id=request.headers.get("audiohook-session-id"),
|
|
302
|
+
)
|
|
303
|
+
# validate required headers
|
|
304
|
+
required_headers = [
|
|
305
|
+
"audiohook-organization-id",
|
|
306
|
+
"audiohook-correlation-id",
|
|
307
|
+
"audiohook-session-id",
|
|
308
|
+
"x-api-key",
|
|
309
|
+
]
|
|
310
|
+
|
|
311
|
+
for header in required_headers:
|
|
312
|
+
if header not in request.headers:
|
|
313
|
+
await ws.close(1008, f"Missing required header: {header}")
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
# TODO: validate API key header
|
|
317
|
+
# process audio streaming
|
|
318
|
+
logger.info("genesys.receive", message="Starting audio streaming")
|
|
319
|
+
await self.run_audio_streaming(on_new_message, ws)
|
|
320
|
+
|
|
321
|
+
return blueprint
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _schedule_ws_task(coro: Awaitable[Any]) -> None:
|
|
325
|
+
"""Helper function to schedule a coroutine in the event loop.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
coro: The coroutine to schedule
|
|
329
|
+
"""
|
|
330
|
+
loop = asyncio.get_running_loop()
|
|
331
|
+
loop.call_soon_threadsafe(lambda: loop.create_task(coro))
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
1
3
|
import os
|
|
2
4
|
from dataclasses import dataclass
|
|
3
5
|
from typing import AsyncIterator, Dict, Optional
|
|
@@ -39,7 +41,7 @@ class CartesiaTTS(TTSEngine[CartesiaTTSConfig]):
|
|
|
39
41
|
@staticmethod
|
|
40
42
|
def get_tts_endpoint() -> str:
|
|
41
43
|
"""Create the endpoint string for cartesia."""
|
|
42
|
-
return "https://api.cartesia.ai/tts/
|
|
44
|
+
return "https://api.cartesia.ai/tts/sse"
|
|
43
45
|
|
|
44
46
|
@staticmethod
|
|
45
47
|
def get_request_body(text: str, config: CartesiaTTSConfig) -> Dict:
|
|
@@ -85,8 +87,19 @@ class CartesiaTTS(TTSEngine[CartesiaTTSConfig]):
|
|
|
85
87
|
url, headers=headers, json=payload, chunked=True
|
|
86
88
|
) as response:
|
|
87
89
|
if 200 <= response.status < 300:
|
|
88
|
-
async for
|
|
89
|
-
|
|
90
|
+
async for chunk in response.content:
|
|
91
|
+
# we are looking for chunks in the response that look like
|
|
92
|
+
# b"data: {..., data: <base64 encoded audio bytes> ...}"
|
|
93
|
+
# and extract the audio bytes from that
|
|
94
|
+
if chunk.startswith(b"data: "):
|
|
95
|
+
json_bytes = chunk[5:-1]
|
|
96
|
+
json_data = json.loads(json_bytes.decode())
|
|
97
|
+
if "data" in json_data:
|
|
98
|
+
base64_encoded_bytes = json_data["data"]
|
|
99
|
+
channel_bytes = base64.b64decode(base64_encoded_bytes)
|
|
100
|
+
yield self.engine_bytes_to_rasa_audio_bytes(
|
|
101
|
+
channel_bytes
|
|
102
|
+
)
|
|
90
103
|
return
|
|
91
104
|
else:
|
|
92
105
|
structlogger.error(
|
|
@@ -98,6 +98,7 @@ class TwilioMediaStreamsInputChannel(VoiceInputChannel):
|
|
|
98
98
|
def map_input_message(
|
|
99
99
|
self,
|
|
100
100
|
message: Any,
|
|
101
|
+
ws: Websocket,
|
|
101
102
|
) -> VoiceChannelAction:
|
|
102
103
|
data = json.loads(message)
|
|
103
104
|
if data["event"] == "media":
|
|
@@ -142,7 +143,7 @@ class TwilioMediaStreamsInputChannel(VoiceInputChannel):
|
|
|
142
143
|
def blueprint(
|
|
143
144
|
self, on_new_message: Callable[[UserMessage], Awaitable[Any]]
|
|
144
145
|
) -> Blueprint:
|
|
145
|
-
"""Defines a Sanic
|
|
146
|
+
"""Defines a Sanic blueprint for the voice input channel."""
|
|
146
147
|
blueprint = Blueprint("twilio_media_streams", __name__)
|
|
147
148
|
|
|
148
149
|
@blueprint.route("/", methods=["GET"])
|
|
@@ -315,6 +315,7 @@ class VoiceInputChannel(InputChannel):
|
|
|
315
315
|
def map_input_message(
|
|
316
316
|
self,
|
|
317
317
|
message: Any,
|
|
318
|
+
ws: Websocket,
|
|
318
319
|
) -> VoiceChannelAction:
|
|
319
320
|
"""Map a channel input message to a voice channel action."""
|
|
320
321
|
raise NotImplementedError
|
|
@@ -340,7 +341,7 @@ class VoiceInputChannel(InputChannel):
|
|
|
340
341
|
async def consume_audio_bytes() -> None:
|
|
341
342
|
async for message in channel_websocket:
|
|
342
343
|
is_bot_speaking_before = call_state.is_bot_speaking
|
|
343
|
-
channel_action = self.map_input_message(message)
|
|
344
|
+
channel_action = self.map_input_message(message, channel_websocket)
|
|
344
345
|
is_bot_speaking_after = call_state.is_bot_speaking
|
|
345
346
|
|
|
346
347
|
if not is_bot_speaking_before and is_bot_speaking_after:
|
|
@@ -54,7 +54,9 @@ from rasa.dialogue_understanding.stack.utils import (
|
|
|
54
54
|
user_flows_on_the_stack,
|
|
55
55
|
)
|
|
56
56
|
from rasa.shared.constants import RASA_PATTERN_HUMAN_HANDOFF
|
|
57
|
-
from rasa.shared.core.constants import
|
|
57
|
+
from rasa.shared.core.constants import (
|
|
58
|
+
ACTION_LISTEN_NAME,
|
|
59
|
+
)
|
|
58
60
|
from rasa.shared.core.events import (
|
|
59
61
|
Event,
|
|
60
62
|
FlowCompleted,
|
|
@@ -564,38 +566,6 @@ def cancel_flow_and_push_internal_error(stack: DialogueStack, flow_name: str) ->
|
|
|
564
566
|
stack.push(InternalErrorPatternFlowStackFrame())
|
|
565
567
|
|
|
566
568
|
|
|
567
|
-
def validate_custom_slot_mappings(
|
|
568
|
-
step: CollectInformationFlowStep,
|
|
569
|
-
stack: DialogueStack,
|
|
570
|
-
tracker: DialogueStateTracker,
|
|
571
|
-
available_actions: List[str],
|
|
572
|
-
flow_name: str,
|
|
573
|
-
) -> bool:
|
|
574
|
-
"""Validate a slot with custom mappings.
|
|
575
|
-
|
|
576
|
-
If invalid, trigger pattern_internal_error and return False.
|
|
577
|
-
"""
|
|
578
|
-
slot = tracker.slots.get(step.collect, None)
|
|
579
|
-
slot_mappings = slot.mappings if slot else []
|
|
580
|
-
for mapping in slot_mappings:
|
|
581
|
-
if (
|
|
582
|
-
mapping.get("type") == SlotMappingType.CUSTOM.value
|
|
583
|
-
and mapping.get("action") is None
|
|
584
|
-
):
|
|
585
|
-
# this is a slot that must be filled by a custom action
|
|
586
|
-
# check if collect_action exists
|
|
587
|
-
if step.collect_action not in available_actions:
|
|
588
|
-
structlogger.error(
|
|
589
|
-
"flow.step.run.collect_action_not_found_for_custom_slot_mapping",
|
|
590
|
-
action=step.collect_action,
|
|
591
|
-
collect=step.collect,
|
|
592
|
-
)
|
|
593
|
-
cancel_flow_and_push_internal_error(stack, flow_name)
|
|
594
|
-
return False
|
|
595
|
-
|
|
596
|
-
return True
|
|
597
|
-
|
|
598
|
-
|
|
599
569
|
def attach_stack_metadata_to_events(
|
|
600
570
|
step_id: str,
|
|
601
571
|
flow_id: str,
|
|
@@ -792,14 +762,6 @@ def _run_collect_information_step(
|
|
|
792
762
|
# if we return any other FlowStepResult, the assistant will stay silent
|
|
793
763
|
# instead of triggering the internal error pattern
|
|
794
764
|
return ContinueFlowWithNextStep(events=initial_events)
|
|
795
|
-
is_mapping_valid = validate_custom_slot_mappings(
|
|
796
|
-
step, stack, tracker, available_actions, flow_name
|
|
797
|
-
)
|
|
798
|
-
|
|
799
|
-
if not is_mapping_valid:
|
|
800
|
-
# if we return any other FlowStepResult, the assistant will stay silent
|
|
801
|
-
# instead of triggering the internal error pattern
|
|
802
|
-
return ContinueFlowWithNextStep(events=initial_events)
|
|
803
765
|
|
|
804
766
|
structlogger.debug("flow.step.run.collect")
|
|
805
767
|
trigger_pattern_ask_collect_information(
|
rasa/core/run.py
CHANGED
|
@@ -283,9 +283,10 @@ def serve_application(
|
|
|
283
283
|
endpoints.lock_store if endpoints else None
|
|
284
284
|
)
|
|
285
285
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
286
|
+
if not inspect:
|
|
287
|
+
telemetry.track_server_start(
|
|
288
|
+
input_channels, endpoints, model_path, number_of_workers, enable_api
|
|
289
|
+
)
|
|
289
290
|
|
|
290
291
|
rasa.utils.common.update_sanic_log_level(
|
|
291
292
|
log_file, use_syslog, syslog_address, syslog_port, syslog_protocol
|