rasa-pro 3.11.0a2__py3-none-any.whl → 3.11.0a4.dev1__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 (51) hide show
  1. README.md +17 -396
  2. rasa/api.py +4 -0
  3. rasa/cli/arguments/train.py +14 -0
  4. rasa/cli/inspect.py +1 -1
  5. rasa/cli/interactive.py +1 -0
  6. rasa/cli/project_templates/calm/endpoints.yml +7 -2
  7. rasa/cli/project_templates/tutorial/endpoints.yml +7 -2
  8. rasa/cli/train.py +3 -0
  9. rasa/constants.py +2 -0
  10. rasa/core/actions/action.py +75 -33
  11. rasa/core/actions/action_repeat_bot_messages.py +72 -0
  12. rasa/core/actions/e2e_stub_custom_action_executor.py +5 -1
  13. rasa/core/actions/http_custom_action_executor.py +4 -0
  14. rasa/core/channels/socketio.py +5 -1
  15. rasa/core/channels/voice_ready/utils.py +6 -5
  16. rasa/core/channels/voice_stream/browser_audio.py +1 -1
  17. rasa/core/channels/voice_stream/twilio_media_streams.py +1 -1
  18. rasa/core/nlg/contextual_response_rephraser.py +19 -2
  19. rasa/core/persistor.py +87 -21
  20. rasa/core/utils.py +53 -22
  21. rasa/dialogue_understanding/commands/__init__.py +4 -0
  22. rasa/dialogue_understanding/commands/repeat_bot_messages_command.py +60 -0
  23. rasa/dialogue_understanding/generator/single_step/command_prompt_template.jinja2 +3 -0
  24. rasa/dialogue_understanding/generator/single_step/single_step_llm_command_generator.py +19 -0
  25. rasa/dialogue_understanding/patterns/default_flows_for_patterns.yml +5 -0
  26. rasa/dialogue_understanding/patterns/repeat.py +37 -0
  27. rasa/e2e_test/utils/io.py +2 -0
  28. rasa/model_manager/__init__.py +0 -0
  29. rasa/model_manager/config.py +18 -0
  30. rasa/model_manager/model_api.py +469 -0
  31. rasa/model_manager/runner_service.py +279 -0
  32. rasa/model_manager/socket_bridge.py +143 -0
  33. rasa/model_manager/studio_jwt_auth.py +86 -0
  34. rasa/model_manager/trainer_service.py +332 -0
  35. rasa/model_manager/utils.py +66 -0
  36. rasa/model_service.py +109 -0
  37. rasa/model_training.py +25 -7
  38. rasa/shared/constants.py +6 -0
  39. rasa/shared/core/constants.py +2 -0
  40. rasa/shared/providers/llm/self_hosted_llm_client.py +15 -3
  41. rasa/shared/utils/yaml.py +10 -1
  42. rasa/utils/endpoints.py +27 -1
  43. rasa/version.py +1 -1
  44. rasa_pro-3.11.0a4.dev1.dist-info/METADATA +197 -0
  45. {rasa_pro-3.11.0a2.dist-info → rasa_pro-3.11.0a4.dev1.dist-info}/RECORD +48 -38
  46. rasa/keys +0 -1
  47. rasa/llm_fine_tuning/notebooks/unsloth_finetuning.ipynb +0 -407
  48. rasa_pro-3.11.0a2.dist-info/METADATA +0 -576
  49. {rasa_pro-3.11.0a2.dist-info → rasa_pro-3.11.0a4.dev1.dist-info}/NOTICE +0 -0
  50. {rasa_pro-3.11.0a2.dist-info → rasa_pro-3.11.0a4.dev1.dist-info}/WHEEL +0 -0
  51. {rasa_pro-3.11.0a2.dist-info → rasa_pro-3.11.0a4.dev1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,279 @@
1
+ import os
2
+ import shutil
3
+ from typing import Dict
4
+ import aiohttp
5
+ import structlog
6
+ import subprocess
7
+ from pydantic import BaseModel, ConfigDict
8
+ from enum import Enum
9
+
10
+ from rasa.exceptions import ModelNotFound
11
+ from rasa.model_manager.utils import (
12
+ models_base_path,
13
+ subpath,
14
+ write_encoded_data_to_file,
15
+ )
16
+ from rasa.constants import MODEL_ARCHIVE_EXTENSION
17
+
18
+ from rasa.model_manager import config
19
+ from rasa.model_manager.utils import logs_path, ensure_base_directory_exists
20
+
21
+ structlogger = structlog.get_logger()
22
+
23
+
24
+ class BotSessionStatus(str, Enum):
25
+ """Enum for the bot status."""
26
+
27
+ QUEUED = "queued"
28
+ RUNNING = "running"
29
+ STOPPED = "stopped"
30
+
31
+
32
+ class BotSession(BaseModel):
33
+ """Store information about a running bot."""
34
+
35
+ model_config = ConfigDict(arbitrary_types_allowed=True)
36
+
37
+ deployment_id: str
38
+ status: BotSessionStatus
39
+ process: subprocess.Popen
40
+ url: str
41
+ internal_url: str
42
+ port: int
43
+
44
+ def is_alive(self) -> bool:
45
+ """Check if the bot is alive."""
46
+ return self.process.poll() is None
47
+
48
+ def is_status_indicating_alive(self) -> bool:
49
+ """Check if the status indicates that the bot is alive."""
50
+ return self.status in {BotSessionStatus.QUEUED, BotSessionStatus.RUNNING}
51
+
52
+ def has_died_recently(self) -> bool:
53
+ """Check if the bot has died recently.
54
+
55
+ Process will indicate that the bot exited,
56
+ but status is not yet updated.
57
+ """
58
+ return self.is_status_indicating_alive() and not self.is_alive()
59
+
60
+ async def completed_startup_recently(self) -> bool:
61
+ """Check if the bot has completed startup recently."""
62
+ return self.status == BotSessionStatus.QUEUED and await is_bot_startup_finished(
63
+ self
64
+ )
65
+
66
+
67
+ def bot_path(deployment_id: str) -> str:
68
+ """Return the path to the bot directory for a given deployment id."""
69
+ return os.path.abspath(
70
+ f"{config.SERVER_BASE_WORKING_DIRECTORY}/bots/{deployment_id}"
71
+ )
72
+
73
+
74
+ async def is_bot_startup_finished(bot: BotSession) -> bool:
75
+ """Send a request to the bot to see if the bot is up and running."""
76
+ health_timeout = aiohttp.ClientTimeout(total=5, sock_connect=2, sock_read=3)
77
+ try:
78
+ async with aiohttp.ClientSession(timeout=health_timeout) as session:
79
+ # can't use /status as by default the bot API is not enabled, only
80
+ # the input channel
81
+ async with session.get(f"{bot.internal_url}/license") as resp:
82
+ return resp.status == 200
83
+ except aiohttp.client_exceptions.ClientConnectorError:
84
+ structlogger.debug(
85
+ "model_runner.bot.not_running_yet", deployment_id=bot.deployment_id
86
+ )
87
+ return False
88
+
89
+
90
+ def update_bot_to_stopped(bot: BotSession) -> None:
91
+ """Set a bots state to stopped."""
92
+ structlogger.info(
93
+ "model_runner.bot_stopped",
94
+ deployment_id=bot.deployment_id,
95
+ status=bot.process.returncode,
96
+ )
97
+ bot.status = BotSessionStatus.STOPPED
98
+
99
+
100
+ def update_bot_to_running(bot: BotSession) -> None:
101
+ """Set a bots state to running."""
102
+ structlogger.info(
103
+ "model_runner.bot_running",
104
+ deployment_id=bot.deployment_id,
105
+ )
106
+ bot.status = BotSessionStatus.RUNNING
107
+
108
+
109
+ def get_open_port() -> int:
110
+ """Get an open port on the system that is not in use yet."""
111
+ # from https://stackoverflow.com/questions/2838244/get-open-tcp-port-in-python/2838309#2838309
112
+ import socket
113
+
114
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
115
+ s.bind(("", 0))
116
+ s.listen(1)
117
+ port = s.getsockname()[1]
118
+ s.close()
119
+ return port
120
+
121
+
122
+ def write_config_data_to_files(encoded_configs: Dict[str, str], base_path: str) -> None:
123
+ """Write the encoded config data to files."""
124
+ for key, value in encoded_configs.items():
125
+ write_encoded_data_to_file(value, subpath(base_path, f"{key}.yml"))
126
+
127
+
128
+ def prepare_bot_directory(
129
+ bot_base_path: str,
130
+ model_name: str,
131
+ encoded_configs: Dict[str, str],
132
+ ) -> None:
133
+ """Prepare the bot directory for a new bot session."""
134
+ if not os.path.exists(bot_base_path):
135
+ os.makedirs(bot_base_path, exist_ok=True)
136
+ else:
137
+ shutil.rmtree(bot_base_path, ignore_errors=True)
138
+
139
+ model_file_name = f"{model_name}.{MODEL_ARCHIVE_EXTENSION}"
140
+ model_path = subpath(models_base_path(), model_file_name)
141
+
142
+ if config.SERVER_MODEL_REMOTE_STORAGE and not os.path.exists(model_path):
143
+ fetch_remote_model_to_dir(
144
+ model_file_name,
145
+ models_base_path(),
146
+ config.SERVER_MODEL_REMOTE_STORAGE,
147
+ )
148
+
149
+ if not os.path.exists(model_path):
150
+ raise ModelNotFound(f"Model '{model_file_name}' not found in '{model_path}'.")
151
+
152
+ os.makedirs(subpath(bot_base_path, "models"), exist_ok=True)
153
+ shutil.copy(
154
+ src=model_path,
155
+ dst=subpath(bot_base_path, "models"),
156
+ )
157
+
158
+ write_config_data_to_files(encoded_configs, bot_base_path)
159
+
160
+
161
+ def fetch_remote_model_to_dir(
162
+ model_name: str, target_path: str, storage_type: str
163
+ ) -> str:
164
+ """Fetch the model from remote storage.
165
+
166
+ Returns the path to the model diretory.
167
+ """
168
+ from rasa.core.persistor import get_persistor
169
+
170
+ persistor = get_persistor(storage_type)
171
+
172
+ # we now there must be a persistor, because the config is set
173
+ # this is here to please the type checker for the call below
174
+ assert persistor is not None
175
+
176
+ try:
177
+ return persistor.retrieve(model_name=model_name, target_path=target_path)
178
+ except FileNotFoundError as e:
179
+ raise ModelNotFound() from e
180
+
181
+
182
+ def start_bot_process(
183
+ deployment_id: str, bot_base_path: str, base_url_path: str
184
+ ) -> BotSession:
185
+ port = get_open_port()
186
+ log_path = logs_path(deployment_id)
187
+
188
+ ensure_base_directory_exists(log_path)
189
+
190
+ full_command = [
191
+ config.RASA_PYTHON_PATH,
192
+ "-m",
193
+ "rasa.__main__",
194
+ "run",
195
+ "--endpoints",
196
+ f"{bot_base_path}/endpoints.yml",
197
+ "--credentials",
198
+ f"{bot_base_path}/credentials.yml",
199
+ "--debug",
200
+ f"--port={port}",
201
+ "--model",
202
+ f"{bot_base_path}/models",
203
+ ]
204
+
205
+ structlogger.debug(
206
+ "model_runner.bot.starting_command",
207
+ deployment_id=deployment_id,
208
+ command=" ".join(full_command),
209
+ )
210
+
211
+ process = subprocess.Popen(
212
+ full_command,
213
+ cwd=bot_base_path,
214
+ stdout=open(log_path, "w"),
215
+ stderr=subprocess.STDOUT,
216
+ env=os.environ.copy(),
217
+ )
218
+
219
+ internal_bot_url = f"http://localhost:{port}"
220
+
221
+ structlogger.info(
222
+ "model_runner.bot.starting",
223
+ deployment_id=deployment_id,
224
+ log=log_path,
225
+ url=internal_bot_url,
226
+ port=port,
227
+ pid=process.pid,
228
+ )
229
+
230
+ return BotSession(
231
+ deployment_id=deployment_id,
232
+ status=BotSessionStatus.QUEUED,
233
+ process=process,
234
+ url=f"{base_url_path}?deployment_id={deployment_id}",
235
+ internal_url=internal_bot_url,
236
+ port=port,
237
+ )
238
+
239
+
240
+ def run_bot(
241
+ deployment_id: str,
242
+ model_name: str,
243
+ base_url_path: str,
244
+ encoded_configs: Dict[str, str],
245
+ ) -> BotSession:
246
+ """Deploy a bot based on a given training id."""
247
+ bot_base_path = bot_path(deployment_id)
248
+ prepare_bot_directory(bot_base_path, model_name, encoded_configs)
249
+
250
+ return start_bot_process(deployment_id, bot_base_path, base_url_path)
251
+
252
+
253
+ async def update_bot_status(bot: BotSession) -> None:
254
+ """Update the status of a bot based on the process return code."""
255
+ if bot.has_died_recently():
256
+ update_bot_to_stopped(bot)
257
+ elif await bot.completed_startup_recently():
258
+ update_bot_to_running(bot)
259
+
260
+
261
+ def terminate_bot(bot: BotSession) -> None:
262
+ """Terminate the bot process."""
263
+ if not bot.is_status_indicating_alive():
264
+ # if the bot is not running, we don't need to terminate it
265
+ return
266
+
267
+ try:
268
+ bot.process.terminate()
269
+ structlogger.info(
270
+ "model_runner.stop_bot.stopped",
271
+ deployment_id=bot.deployment_id,
272
+ status=bot.process.returncode,
273
+ )
274
+ bot.status = BotSessionStatus.STOPPED
275
+ except ProcessLookupError:
276
+ structlogger.debug(
277
+ "model_runner.stop_bot.process_not_found",
278
+ deployment_id=bot.deployment_id,
279
+ )
@@ -0,0 +1,143 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from socketio import AsyncServer
4
+ import structlog
5
+ from socketio.asyncio_client import AsyncClient
6
+
7
+ from rasa.model_manager.runner_service import BotSession
8
+ from rasa.model_manager.studio_jwt_auth import (
9
+ UserToServiceAuthenticationError,
10
+ authenticate_user_to_service,
11
+ )
12
+
13
+ structlogger = structlog.get_logger()
14
+
15
+
16
+ # A simple in-memory store for active chat connections to studio frontend
17
+ socket_proxy_clients = {}
18
+
19
+
20
+ async def socketio_websocket_traffic_wrapper(
21
+ sio: AsyncServer,
22
+ running_bots: Dict[str, BotSession],
23
+ sid: str,
24
+ auth: Optional[Dict],
25
+ ) -> bool:
26
+ """Wrapper for bridging the user chat websocket and the bot server."""
27
+ auth_token = auth.get("token") if auth else None
28
+
29
+ if auth_token is None:
30
+ structlogger.error("model_runner.user_no_token", sid=sid)
31
+ return False
32
+
33
+ try:
34
+ authenticate_user_to_service(auth_token)
35
+ structlogger.debug("model_runner.user_authenticated_successfully", sid=sid)
36
+ except UserToServiceAuthenticationError as error:
37
+ structlogger.error(
38
+ "model_runner.user_authentication_failed", sid=sid, error=str(error)
39
+ )
40
+ return False
41
+
42
+ deployment_id = auth.get("deployment_id") if auth else None
43
+
44
+ if deployment_id is None:
45
+ structlogger.error("model_runner.bot_no_deployment_id", sid=sid)
46
+ return False
47
+
48
+ bot = running_bots.get(deployment_id)
49
+ if bot is None:
50
+ structlogger.error("model_runner.bot_not_found", deployment_id=deployment_id)
51
+ return False
52
+
53
+ if not bot.is_alive():
54
+ structlogger.error("model_runner.bot_not_alive", deployment_id=deployment_id)
55
+ return False
56
+
57
+ client = await create_bridge_client(sio, bot.internal_url, sid, deployment_id)
58
+
59
+ if client.sid is not None:
60
+ structlogger.debug(
61
+ "model_runner.bot_connection_established", deployment_id=deployment_id
62
+ )
63
+ socket_proxy_clients[sid] = client
64
+ return True
65
+ else:
66
+ structlogger.error(
67
+ "model_runner.bot_connection_failed", deployment_id=deployment_id
68
+ )
69
+ return False
70
+
71
+
72
+ def create_bridge_server(sio: AsyncServer, running_bots: Dict[str, BotSession]) -> None:
73
+ """Create handlers for the socket server side.
74
+
75
+ Forwards messages coming from the user to the bot.
76
+ """
77
+
78
+ @sio.on("connect")
79
+ async def socketio_websocket_traffic(
80
+ sid: str, environ: Dict, auth: Optional[Dict]
81
+ ) -> bool:
82
+ """Bridge websockets between user chat socket and bot server."""
83
+ return await socketio_websocket_traffic_wrapper(sio, running_bots, sid, auth)
84
+
85
+ @sio.on("disconnect")
86
+ async def disconnect(sid: str) -> None:
87
+ structlogger.debug("model_runner.bot_disconnect", sid=sid)
88
+ if sid in socket_proxy_clients:
89
+ await socket_proxy_clients[sid].disconnect()
90
+ del socket_proxy_clients[sid]
91
+
92
+ @sio.on("*")
93
+ async def handle_message(event: str, sid: str, data: Dict[str, Any]) -> None:
94
+ # bridge both, incoming messages to the bot_url but also
95
+ # send the response back to the client. both need to happen
96
+ # in parallel in an async way
97
+
98
+ client = socket_proxy_clients.get(sid)
99
+ if client is None:
100
+ structlogger.error("model_runner.bot_not_connected", sid=sid)
101
+ return
102
+
103
+ await client.emit(event, data)
104
+
105
+
106
+ async def create_bridge_client(
107
+ sio: AsyncServer, url: str, sid: str, deployment_id: str
108
+ ) -> AsyncClient:
109
+ """Create a new socket bridge client.
110
+
111
+ Forwards messages comming from the bot to the user.
112
+ """
113
+ client = AsyncClient()
114
+
115
+ await client.connect(url)
116
+
117
+ @client.event # type: ignore[misc]
118
+ async def session_confirm(data: Dict[str, Any]) -> None:
119
+ structlogger.debug(
120
+ "model_runner.bot_session_confirmed", deployment_id=deployment_id
121
+ )
122
+ await sio.emit("session_confirm", room=sid)
123
+
124
+ @client.event # type: ignore[misc]
125
+ async def bot_message(data: Dict[str, Any]) -> None:
126
+ structlogger.debug("model_runner.bot_message", deployment_id=deployment_id)
127
+ await sio.emit("bot_message", data, room=sid)
128
+
129
+ @client.event # type: ignore[misc]
130
+ async def disconnect() -> None:
131
+ structlogger.debug(
132
+ "model_runner.bot_connection_closed", deployment_id=deployment_id
133
+ )
134
+ await sio.emit("disconnect", room=sid)
135
+
136
+ @client.event # type: ignore[misc]
137
+ async def connect_error() -> None:
138
+ structlogger.error(
139
+ "model_runner.bot_connection_error", deployment_id=deployment_id
140
+ )
141
+ await sio.emit("disconnect", room=sid)
142
+
143
+ return client
@@ -0,0 +1,86 @@
1
+ import os
2
+ from functools import cache
3
+ from http import HTTPStatus
4
+ from typing import Any, Dict, Optional
5
+
6
+ import jwt
7
+ import requests
8
+ import structlog
9
+ from rasa.shared.exceptions import RasaException
10
+
11
+ structlogger = structlog.get_logger()
12
+
13
+
14
+ AUTH_URL = os.getenv("KEYCLOAK_URL", "http://localhost:8081/auth")
15
+
16
+ AUTH_REALM = os.getenv("KEYCLOAK_REALM", "rasa-studio")
17
+
18
+
19
+ class UserToServiceAuthenticationError(RasaException):
20
+ """Raised when the user authentication fails."""
21
+
22
+ def __init__(self, message: str) -> None:
23
+ self.message = message
24
+
25
+ def __str__(self) -> str:
26
+ return f"{self.__class__.__name__}: {self.message}"
27
+
28
+
29
+ @cache
30
+ def get_public_key_from_keycloak() -> Optional[str]:
31
+ """Fetch the public key from the keycloak server."""
32
+ realm_url = f"{AUTH_URL}/realms/{AUTH_REALM}"
33
+
34
+ try:
35
+ response = requests.get(realm_url)
36
+ except requests.RequestException as error:
37
+ structlogger.error("model_api.auth.keycloak_request_failed", error=str(error))
38
+ return None
39
+
40
+ if response.status_code != HTTPStatus.OK:
41
+ structlogger.error(
42
+ "model_api.auth.keycloak_public_key_fetch_failed",
43
+ status_code=response.status_code,
44
+ response=response.text,
45
+ )
46
+ return None
47
+
48
+ public_key = response.json().get("public_key")
49
+
50
+ if public_key is None:
51
+ structlogger.error(
52
+ "model_runner.keycloak_public_key_not_found",
53
+ response=response.text,
54
+ )
55
+ return None
56
+
57
+ public_key = f"-----BEGIN PUBLIC KEY-----\n{public_key}\n-----END PUBLIC KEY-----"
58
+ return public_key
59
+
60
+
61
+ def authenticate_user_to_service(token: str) -> Dict[str, Any]:
62
+ """Authenticate the user to the model service."""
63
+ if not token:
64
+ structlogger.debug("model_api.auth.no_token_provided")
65
+ raise UserToServiceAuthenticationError("No token provided.")
66
+
67
+ public_key = get_public_key_from_keycloak()
68
+
69
+ if public_key is None:
70
+ raise UserToServiceAuthenticationError(
71
+ "Failed to fetch public key from keycloak."
72
+ )
73
+
74
+ try:
75
+ return jwt.decode(
76
+ token,
77
+ public_key,
78
+ algorithms=["RS256", "HS256", "HS512", "ES256"],
79
+ audience="account",
80
+ )
81
+ except jwt.InvalidKeyError as error:
82
+ structlogger.info("model_api.auth.invalid_jwt_key", error=str(error))
83
+ raise UserToServiceAuthenticationError("Invalid JWT key.")
84
+ except jwt.InvalidTokenError as error:
85
+ structlogger.info("model_api.auth.invalid_jwt_token", error=str(error))
86
+ raise UserToServiceAuthenticationError("Invalid JWT token.") from error