rasa-pro 3.14.1__py3-none-any.whl → 3.15.0a3__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 (69) hide show
  1. rasa/builder/config.py +4 -0
  2. rasa/builder/constants.py +5 -0
  3. rasa/builder/copilot/copilot.py +28 -9
  4. rasa/builder/copilot/models.py +251 -32
  5. rasa/builder/document_retrieval/inkeep_document_retrieval.py +2 -0
  6. rasa/builder/download.py +111 -1
  7. rasa/builder/evaluator/__init__.py +0 -0
  8. rasa/builder/evaluator/constants.py +15 -0
  9. rasa/builder/evaluator/copilot_executor.py +89 -0
  10. rasa/builder/evaluator/dataset/models.py +173 -0
  11. rasa/builder/evaluator/exceptions.py +4 -0
  12. rasa/builder/evaluator/response_classification/__init__.py +0 -0
  13. rasa/builder/evaluator/response_classification/constants.py +66 -0
  14. rasa/builder/evaluator/response_classification/evaluator.py +346 -0
  15. rasa/builder/evaluator/response_classification/langfuse_runner.py +463 -0
  16. rasa/builder/evaluator/response_classification/models.py +61 -0
  17. rasa/builder/evaluator/scripts/__init__.py +0 -0
  18. rasa/builder/evaluator/scripts/run_response_classification_evaluator.py +152 -0
  19. rasa/builder/jobs.py +208 -1
  20. rasa/builder/logging_utils.py +25 -24
  21. rasa/builder/main.py +6 -1
  22. rasa/builder/models.py +23 -0
  23. rasa/builder/project_generator.py +29 -10
  24. rasa/builder/service.py +205 -46
  25. rasa/builder/telemetry/__init__.py +0 -0
  26. rasa/builder/telemetry/copilot_langfuse_telemetry.py +384 -0
  27. rasa/builder/{copilot/telemetry.py → telemetry/copilot_segment_telemetry.py} +21 -3
  28. rasa/builder/training_service.py +13 -1
  29. rasa/builder/validation_service.py +2 -1
  30. rasa/constants.py +1 -0
  31. rasa/core/actions/action_clean_stack.py +32 -0
  32. rasa/core/actions/constants.py +4 -0
  33. rasa/core/actions/custom_action_executor.py +70 -12
  34. rasa/core/actions/grpc_custom_action_executor.py +41 -2
  35. rasa/core/actions/http_custom_action_executor.py +49 -25
  36. rasa/core/channels/voice_stream/voice_channel.py +14 -2
  37. rasa/core/policies/flows/flow_executor.py +20 -6
  38. rasa/core/run.py +15 -4
  39. rasa/dialogue_understanding/generator/llm_based_command_generator.py +6 -3
  40. rasa/dialogue_understanding/generator/single_step/compact_llm_command_generator.py +15 -7
  41. rasa/dialogue_understanding/generator/single_step/search_ready_llm_command_generator.py +15 -8
  42. rasa/dialogue_understanding/processor/command_processor.py +49 -7
  43. rasa/e2e_test/e2e_config.py +4 -3
  44. rasa/engine/recipes/default_components.py +16 -6
  45. rasa/graph_components/validators/default_recipe_validator.py +10 -4
  46. rasa/nlu/classifiers/diet_classifier.py +2 -0
  47. rasa/shared/core/slots.py +55 -24
  48. rasa/shared/providers/_configs/azure_openai_client_config.py +4 -5
  49. rasa/shared/providers/_configs/default_litellm_client_config.py +4 -4
  50. rasa/shared/providers/_configs/litellm_router_client_config.py +3 -2
  51. rasa/shared/providers/_configs/openai_client_config.py +5 -7
  52. rasa/shared/providers/_configs/rasa_llm_client_config.py +4 -4
  53. rasa/shared/providers/_configs/self_hosted_llm_client_config.py +4 -4
  54. rasa/shared/providers/llm/_base_litellm_client.py +42 -14
  55. rasa/shared/providers/llm/litellm_router_llm_client.py +38 -15
  56. rasa/shared/providers/llm/self_hosted_llm_client.py +34 -32
  57. rasa/shared/utils/common.py +9 -1
  58. rasa/shared/utils/configs.py +5 -8
  59. rasa/utils/common.py +9 -0
  60. rasa/utils/endpoints.py +8 -0
  61. rasa/utils/installation_utils.py +111 -0
  62. rasa/utils/tensorflow/callback.py +2 -0
  63. rasa/utils/train_utils.py +2 -0
  64. rasa/version.py +1 -1
  65. {rasa_pro-3.14.1.dist-info → rasa_pro-3.15.0a3.dist-info}/METADATA +15 -13
  66. {rasa_pro-3.14.1.dist-info → rasa_pro-3.15.0a3.dist-info}/RECORD +69 -53
  67. {rasa_pro-3.14.1.dist-info → rasa_pro-3.15.0a3.dist-info}/NOTICE +0 -0
  68. {rasa_pro-3.14.1.dist-info → rasa_pro-3.15.0a3.dist-info}/WHEEL +0 -0
  69. {rasa_pro-3.14.1.dist-info → rasa_pro-3.15.0a3.dist-info}/entry_points.txt +0 -0
rasa/builder/jobs.py CHANGED
@@ -1,9 +1,16 @@
1
- from typing import Any, Dict, Optional
1
+ import tarfile
2
+ from pathlib import Path
3
+ from typing import Any, Dict, List, Optional
2
4
 
3
5
  import structlog
4
6
  from sanic import Sanic
5
7
 
6
8
  from rasa.builder import config
9
+ from rasa.builder.constants import (
10
+ MAX_ARCHIVE_FILE_SIZE,
11
+ MAX_ARCHIVE_FILES,
12
+ MAX_ARCHIVE_TOTAL_SIZE,
13
+ )
7
14
  from rasa.builder.copilot.constants import (
8
15
  PROMPT_TO_BOT_KEY,
9
16
  )
@@ -19,6 +26,7 @@ from rasa.builder.copilot.models import (
19
26
  ResponseCategory,
20
27
  TrainingErrorLog,
21
28
  )
29
+ from rasa.builder.download import download_backup_from_url
22
30
  from rasa.builder.exceptions import (
23
31
  LLMGenerationError,
24
32
  ProjectGenerationError,
@@ -39,6 +47,11 @@ from rasa.builder.training_service import (
39
47
  )
40
48
  from rasa.builder.validation_service import validate_project
41
49
  from rasa.cli.scaffold import ProjectTemplateName
50
+ from rasa.core.agent import load_agent
51
+ from rasa.core.config.configuration import Configuration
52
+ from rasa.exceptions import ModelNotFound
53
+ from rasa.model import get_local_model
54
+ from rasa.shared.constants import DEFAULT_ENDPOINTS_PATH
42
55
 
43
56
  structlogger = structlog.get_logger()
44
57
 
@@ -607,3 +620,197 @@ async def run_copilot_training_success_job(
607
620
  )
608
621
  await push_job_status_event(job, JobStatus.error, message=str(exc))
609
622
  job_manager.mark_done(job, error=str(exc))
623
+
624
+
625
+ def _safe_tar_members(
626
+ tar: tarfile.TarFile, destination_directory: Path
627
+ ) -> List[tarfile.TarInfo]:
628
+ """Get safe members for extraction to prevent path traversal and resource attacks.
629
+
630
+ Args:
631
+ tar: Open tar file handle
632
+ destination_directory: Directory to which files will be extracted
633
+
634
+ Returns:
635
+ List of members that are safe to extract within destination_directory
636
+
637
+ Raises:
638
+ ProjectGenerationError: If archive violates security constraints
639
+ """
640
+ base_path = destination_directory.resolve()
641
+ safe_members = []
642
+ total_size = 0
643
+ file_count = 0
644
+
645
+ for member in tar.getmembers():
646
+ name = member.name
647
+
648
+ # Check file count limit
649
+ file_count += 1
650
+ if file_count > MAX_ARCHIVE_FILES:
651
+ raise ProjectGenerationError(
652
+ f"Archive contains too many files (>{MAX_ARCHIVE_FILES}).", attempts=1
653
+ )
654
+
655
+ # Skip empty names and absolute paths
656
+ if not name or name.startswith("/") or name.startswith("\\"):
657
+ continue
658
+
659
+ # Disallow symlinks and hardlinks
660
+ if member.issym() or member.islnk():
661
+ continue
662
+
663
+ # Check individual file size limit
664
+ if member.size > MAX_ARCHIVE_FILE_SIZE:
665
+ raise ProjectGenerationError(
666
+ f"Archive contains file '{name}' that is too large "
667
+ f"({member.size} bytes > {MAX_ARCHIVE_FILE_SIZE} bytes).",
668
+ attempts=1,
669
+ )
670
+
671
+ # Check total size limit
672
+ total_size += member.size
673
+ if total_size > MAX_ARCHIVE_TOTAL_SIZE:
674
+ raise ProjectGenerationError(
675
+ "Archive total size too large "
676
+ f"({total_size} bytes > {MAX_ARCHIVE_TOTAL_SIZE} bytes).",
677
+ attempts=1,
678
+ )
679
+
680
+ # Compute the final path and ensure it's within base_path
681
+ target_path = (base_path / name).resolve()
682
+ try:
683
+ target_path.relative_to(base_path)
684
+ except ValueError:
685
+ # Member would escape the destination directory
686
+ continue
687
+
688
+ safe_members.append(member)
689
+
690
+ return safe_members
691
+
692
+
693
+ async def run_backup_to_bot_job(
694
+ app: "Sanic",
695
+ job: JobInfo,
696
+ presigned_url: str,
697
+ ) -> None:
698
+ """Run the backup-to-bot job in the background.
699
+
700
+ Args:
701
+ app: The Sanic application instance.
702
+ job: The job information instance.
703
+ presigned_url: Presigned URL to download tar.gz backup data.
704
+ """
705
+ project_generator: ProjectGenerator = app.ctx.project_generator
706
+ await push_job_status_event(job, JobStatus.received)
707
+
708
+ temp_file_path = None
709
+ try:
710
+ # 1) Download and extract backup
711
+ await push_job_status_event(job, JobStatus.generating)
712
+ temp_file_path = await download_backup_from_url(presigned_url)
713
+
714
+ # Clear existing project files, keeping .rasa and __pycache__
715
+ project_path = Path(project_generator.project_folder)
716
+ project_generator.cleanup(skip_files=[".rasa", "__pycache__"])
717
+
718
+ # Extract the backup archive
719
+ with tarfile.open(temp_file_path, "r:gz") as tar:
720
+ safe_members = _safe_tar_members(tar, project_path)
721
+ tar.extractall(path=project_path, members=safe_members) # nosec B202:tarfile_unsafe_members
722
+
723
+ await push_job_status_event(job, JobStatus.generation_success)
724
+
725
+ # 2) Load existing model or train new one
726
+ models_dir = project_path / "models"
727
+ try:
728
+ latest_model = get_local_model(str(models_dir))
729
+ except ModelNotFound:
730
+ latest_model = None
731
+
732
+ if latest_model:
733
+ # Load existing model
734
+ structlogger.info(
735
+ "backup_to_bot_job.loading_existing_model",
736
+ job_id=job.id,
737
+ model_path=latest_model,
738
+ )
739
+ await push_job_status_event(job, JobStatus.training)
740
+ available_endpoints = Configuration.initialise_endpoints(
741
+ endpoints_path=project_path / DEFAULT_ENDPOINTS_PATH
742
+ ).endpoints
743
+ agent = await load_agent(
744
+ model_path=latest_model, endpoints=available_endpoints
745
+ )
746
+ update_agent(agent, app)
747
+ await push_job_status_event(job, JobStatus.train_success)
748
+ else:
749
+ # Train new model
750
+ await push_job_status_event(job, JobStatus.training)
751
+ training_input = project_generator.get_training_input()
752
+ agent = await train_and_load_agent(training_input)
753
+ update_agent(agent, app)
754
+ await push_job_status_event(job, JobStatus.train_success)
755
+
756
+ # 3) Complete successfully
757
+ bot_files = project_generator.get_bot_files()
758
+ structlogger.info(
759
+ "bot_builder_service.backup_to_bot.success",
760
+ files_restored=list(bot_files.keys()),
761
+ had_existing_model=bool(latest_model),
762
+ )
763
+ await push_job_status_event(job, JobStatus.done)
764
+ job_manager.mark_done(job)
765
+
766
+ except tarfile.ReadError as exc:
767
+ raise ProjectGenerationError(
768
+ f"Failed to extract backup archive: {exc}. "
769
+ f"Please ensure the backup file is a valid tar.gz archive.",
770
+ attempts=1,
771
+ )
772
+ except TrainingError as exc:
773
+ structlogger.debug(
774
+ "backup_to_bot_job.training_error", job_id=job.id, error=str(exc)
775
+ )
776
+ await push_job_status_event(job, JobStatus.train_error, message=str(exc))
777
+ job_manager.mark_done(job, error=str(exc))
778
+
779
+ except ValidationError as exc:
780
+ log_levels = ["error"]
781
+ if config.VALIDATION_FAIL_ON_WARNINGS:
782
+ log_levels.append("warning")
783
+
784
+ structlogger.debug(
785
+ "backup_to_bot_job.validation_error",
786
+ job_id=job.id,
787
+ error=str(exc),
788
+ all_validation_logs=exc.validation_logs,
789
+ included_log_levels=log_levels,
790
+ )
791
+ error_message = exc.get_error_message_with_logs(log_levels=log_levels)
792
+ await push_job_status_event(
793
+ job, JobStatus.validation_error, message=error_message
794
+ )
795
+ job_manager.mark_done(job, error=error_message)
796
+
797
+ except ProjectGenerationError as exc:
798
+ structlogger.debug(
799
+ "backup_to_bot_job.generation_error", job_id=job.id, error=str(exc)
800
+ )
801
+ await push_job_status_event(job, JobStatus.generation_error, message=str(exc))
802
+ job_manager.mark_done(job, error=str(exc))
803
+
804
+ except Exception as exc:
805
+ structlogger.exception(
806
+ "backup_to_bot_job.unexpected_error", job_id=job.id, error=str(exc)
807
+ )
808
+ await push_job_status_event(job, JobStatus.error, message=str(exc))
809
+ job_manager.mark_done(job, error=str(exc))
810
+ finally:
811
+ # Always clean up temp file
812
+ if temp_file_path:
813
+ try:
814
+ Path(temp_file_path).unlink(missing_ok=True)
815
+ except Exception:
816
+ pass
@@ -1,6 +1,7 @@
1
1
  """Logging and Sentry utilities for the builder service."""
2
2
 
3
3
  import collections
4
+ import contextvars
4
5
  import logging
5
6
  import threading
6
7
  import time
@@ -20,8 +21,10 @@ structlogger = structlog.get_logger()
20
21
  # Thread-safe deque for collecting recent logs
21
22
  _recent_logs: Deque[str] = collections.deque(maxlen=config.MAX_LOG_ENTRIES)
22
23
  _logs_lock = threading.RLock()
23
- # Thread-local storage for validation logs
24
- _validation_logs = threading.local()
24
+ # Context variable for validation logs (async-safe)
25
+ _validation_logs: contextvars.ContextVar[Optional[List[Dict[str, Any]]]] = (
26
+ contextvars.ContextVar("validation_logs", default=None)
27
+ )
25
28
 
26
29
 
27
30
  def collecting_logs_processor(
@@ -42,11 +45,12 @@ def collecting_logs_processor(
42
45
 
43
46
 
44
47
  def collecting_validation_logs_processor(
45
- logger: Any, method_name: str, event_dict: Dict[str, Any]
46
- ) -> Dict[str, Any]:
47
- """Structlog processor that captures validation logs in thread-local storage.
48
+ logger: Any, method_name: str, event_dict: MutableMapping[str, Any]
49
+ ) -> MutableMapping[str, Any]:
50
+ """Structlog processor that captures validation logs in context variable storage.
48
51
 
49
52
  It's designed to be used with the capture_validation_logs context manager.
53
+ Uses contextvars for async-safe log capture across async tasks.
50
54
 
51
55
  Args:
52
56
  logger: The structlog logger instance
@@ -57,41 +61,38 @@ def collecting_validation_logs_processor(
57
61
  The unmodified event_dict (this processor doesn't modify the log data)
58
62
  """
59
63
  # Only capture logs if we're in a validation context
60
- # (logs list exists for this thread)
61
- if hasattr(_validation_logs, "logs"):
64
+ # (logs list exists in the current context)
65
+ logs = _validation_logs.get()
66
+ if logs is not None:
62
67
  log_entry = {"log_level": method_name, **event_dict}
63
- _validation_logs.logs.append(log_entry)
68
+ logs.append(log_entry)
64
69
 
65
70
  return event_dict
66
71
 
67
72
 
68
73
  @contextmanager
69
74
  def capture_validation_logs() -> Generator[List[Dict[str, Any]], Any, None]:
70
- """Context manager to capture validation logs using thread-local storage.
75
+ """Context manager to capture validation logs using context variables.
71
76
 
72
- This context manager temporarily reconfigures structlog to capture all logs
73
- during validation and stores them in thread-local storage. It's thread-safe
74
- and automatically cleans up after use.
77
+ This context manager stores logs in a context variable WITHOUT reconfiguring
78
+ structlog globally. The processor checks the context variable and captures
79
+ logs if present. This avoids race conditions with concurrent requests.
75
80
 
76
81
  Yields:
77
82
  A list of captured log entries, each containing the log level and all
78
83
  original log data from the event_dict.
79
84
  """
80
- # Temporarily reconfigure structlog to add our capture processor
81
- original_processors = structlog.get_config()["processors"]
82
- new_processors = [collecting_validation_logs_processor] + original_processors
83
- structlog.configure(processors=new_processors)
84
-
85
- # Initialize thread-local logs storage
86
- _validation_logs.logs = []
85
+ # Initialize context variable logs storage
86
+ # The processor is ALWAYS installed (see module init), it just checks
87
+ # this context var
88
+ logs: List[Dict[str, Any]] = []
89
+ token = _validation_logs.set(logs)
87
90
 
88
91
  try:
89
- yield _validation_logs.logs
92
+ yield logs
90
93
  finally:
91
- # Restore original configuration and clean up thread-local storage
92
- structlog.configure(processors=original_processors)
93
- if hasattr(_validation_logs, "logs"):
94
- delattr(_validation_logs, "logs")
94
+ # Clean up context variable
95
+ _validation_logs.reset(token)
95
96
 
96
97
 
97
98
  def attach_request_id_processor(
rasa/builder/main.py CHANGED
@@ -18,6 +18,7 @@ from rasa.builder import config
18
18
  from rasa.builder.logging_utils import (
19
19
  attach_request_id_processor,
20
20
  collecting_logs_processor,
21
+ collecting_validation_logs_processor,
21
22
  log_request_end,
22
23
  log_request_start,
23
24
  )
@@ -47,7 +48,11 @@ def setup_logging() -> None:
47
48
  configure_structlog(
48
49
  log_level,
49
50
  include_time=True,
50
- additional_processors=[attach_request_id_processor, collecting_logs_processor],
51
+ additional_processors=[
52
+ attach_request_id_processor,
53
+ collecting_logs_processor,
54
+ collecting_validation_logs_processor,
55
+ ],
51
56
  )
52
57
 
53
58
 
rasa/builder/models.py CHANGED
@@ -49,6 +49,29 @@ class TemplateRequest(BaseModel):
49
49
  return v
50
50
 
51
51
 
52
+ class RestoreFromBackupRequest(BaseModel):
53
+ """Request model for backup-to-bot endpoint."""
54
+
55
+ presigned_url: str = Field(
56
+ ...,
57
+ min_length=1,
58
+ description="Presigned URL to download tar.gz backup file.",
59
+ )
60
+
61
+ @field_validator("presigned_url")
62
+ @classmethod
63
+ def validate_presigned_url(cls, v: str) -> str:
64
+ if not v.strip():
65
+ raise ValueError("Presigned URL cannot be empty or whitespace only")
66
+
67
+ # Basic URL validation
68
+ url = v.strip()
69
+ if not url.startswith(("http://", "https://")):
70
+ raise ValueError("Presigned URL must be a valid HTTP/HTTPS URL")
71
+
72
+ return url
73
+
74
+
52
75
  class BotDataUpdateRequest(BaseModel):
53
76
  """Request model for bot data updates."""
54
77
 
@@ -226,6 +226,7 @@ class ProjectGenerator:
226
226
  self,
227
227
  allowed_file_extensions: Optional[List[str]] = None,
228
228
  exclude_docs_directory: bool = False,
229
+ exclude_models_directory: bool = True,
229
230
  ) -> BotFiles:
230
231
  """Get the current bot files by reading from disk.
231
232
 
@@ -234,13 +235,14 @@ class ProjectGenerator:
234
235
  If None, fetch all files. If provided, only fetch files with matching
235
236
  extensions. Use `""` empty string to allow files with no extensions.
236
237
  exclude_docs_directory: Optional boolean indicating whether to exclude.
238
+ exclude_models_directory: Optional boolean indicating whether to exclude.
237
239
 
238
240
  Returns:
239
241
  Dictionary of file contents with relative paths as keys
240
242
  """
241
243
  bot_files: BotFiles = {}
242
244
 
243
- for file in self.bot_file_paths():
245
+ for file in self.bot_file_paths(exclude_models_directory):
244
246
  relative_path = file.relative_to(self.project_folder)
245
247
 
246
248
  # Exclude the docs directory if specified
@@ -266,7 +268,9 @@ class ProjectGenerator:
266
268
  bot_files[relative_path.as_posix()] = None
267
269
  return bot_files
268
270
 
269
- def is_restricted_path(self, path: Path) -> bool:
271
+ def is_restricted_path(
272
+ self, path: Path, exclude_models_directory: bool = True
273
+ ) -> bool:
270
274
  """Check if the path is restricted.
271
275
 
272
276
  These paths are excluded from deletion and editing by the user.
@@ -281,19 +285,21 @@ class ProjectGenerator:
281
285
  if "__pycache__" in relative_path.parts:
282
286
  return True
283
287
 
284
- # exclude the project_folder / models folder
285
- if relative_path.parts[0] == DEFAULT_MODELS_PATH:
288
+ # exclude the project_folder / models folder if specified
289
+ if exclude_models_directory and relative_path.parts[0] == DEFAULT_MODELS_PATH:
286
290
  return True
287
291
 
288
292
  return False
289
293
 
290
294
  def bot_file_paths(
291
- self,
295
+ self, exclude_models_directory: bool = True
292
296
  ) -> Generator[Path, None, None]:
293
297
  """Get the paths of all bot files."""
294
298
  for file in self.project_folder.glob("**/*"):
295
299
  # Skip directories
296
- if not file.is_file() or self.is_restricted_path(file):
300
+ if not file.is_file() or self.is_restricted_path(
301
+ file, exclude_models_directory
302
+ ):
297
303
  continue
298
304
 
299
305
  yield file
@@ -373,7 +379,10 @@ class ProjectGenerator:
373
379
  self.ensure_all_files_are_writable(files)
374
380
  # Collect all existing files - any files not in the new `files` dict will be
375
381
  # deleted from this set
376
- existing_files = set(path.as_posix() for path in self.bot_file_paths())
382
+ existing_files = set(
383
+ path.as_posix()
384
+ for path in self.bot_file_paths(exclude_models_directory=True)
385
+ )
377
386
 
378
387
  # Write all new files
379
388
  for filename, content in files.items():
@@ -442,14 +451,24 @@ class ProjectGenerator:
442
451
  extra={"directory": relative_path.as_posix()},
443
452
  )
444
453
 
445
- def cleanup(self) -> None:
446
- """Cleanup the project folder."""
454
+ def cleanup(self, skip_files: Optional[List[str]] = None) -> None:
455
+ """Cleanup the project folder.
456
+
457
+ Args:
458
+ skip_files: List of file/directory names to skip during cleanup.
459
+ """
460
+ if skip_files is None:
461
+ skip_files = []
462
+
463
+ # Always include "lost+found" in skip files
464
+ skip_files = list(skip_files) + ["lost+found"]
465
+
447
466
  # remove all the files and folders in the project folder resulting
448
467
  # in an empty folder
449
468
  for filename in os.listdir(self.project_folder):
450
469
  file_path = os.path.join(self.project_folder, filename)
451
470
  try:
452
- if filename == "lost+found":
471
+ if filename in skip_files:
453
472
  continue
454
473
  if os.path.isfile(file_path) or os.path.islink(file_path):
455
474
  os.unlink(file_path)