rasa-pro 3.15.0a1__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 (50) hide show
  1. rasa/builder/constants.py +5 -0
  2. rasa/builder/copilot/models.py +80 -28
  3. rasa/builder/download.py +110 -0
  4. rasa/builder/evaluator/__init__.py +0 -0
  5. rasa/builder/evaluator/constants.py +15 -0
  6. rasa/builder/evaluator/copilot_executor.py +89 -0
  7. rasa/builder/evaluator/dataset/models.py +173 -0
  8. rasa/builder/evaluator/exceptions.py +4 -0
  9. rasa/builder/evaluator/response_classification/__init__.py +0 -0
  10. rasa/builder/evaluator/response_classification/constants.py +66 -0
  11. rasa/builder/evaluator/response_classification/evaluator.py +346 -0
  12. rasa/builder/evaluator/response_classification/langfuse_runner.py +463 -0
  13. rasa/builder/evaluator/response_classification/models.py +61 -0
  14. rasa/builder/evaluator/scripts/__init__.py +0 -0
  15. rasa/builder/evaluator/scripts/run_response_classification_evaluator.py +152 -0
  16. rasa/builder/jobs.py +208 -1
  17. rasa/builder/logging_utils.py +25 -24
  18. rasa/builder/main.py +6 -1
  19. rasa/builder/models.py +23 -0
  20. rasa/builder/project_generator.py +29 -10
  21. rasa/builder/service.py +104 -22
  22. rasa/builder/training_service.py +13 -1
  23. rasa/builder/validation_service.py +2 -1
  24. rasa/core/actions/action_clean_stack.py +32 -0
  25. rasa/core/actions/constants.py +4 -0
  26. rasa/core/actions/custom_action_executor.py +70 -12
  27. rasa/core/actions/grpc_custom_action_executor.py +41 -2
  28. rasa/core/actions/http_custom_action_executor.py +49 -25
  29. rasa/core/channels/voice_stream/voice_channel.py +14 -2
  30. rasa/dialogue_understanding/generator/llm_based_command_generator.py +6 -3
  31. rasa/dialogue_understanding/generator/single_step/compact_llm_command_generator.py +15 -7
  32. rasa/dialogue_understanding/generator/single_step/search_ready_llm_command_generator.py +15 -8
  33. rasa/dialogue_understanding/processor/command_processor.py +49 -7
  34. rasa/shared/providers/_configs/azure_openai_client_config.py +4 -5
  35. rasa/shared/providers/_configs/default_litellm_client_config.py +4 -4
  36. rasa/shared/providers/_configs/litellm_router_client_config.py +3 -2
  37. rasa/shared/providers/_configs/openai_client_config.py +5 -7
  38. rasa/shared/providers/_configs/rasa_llm_client_config.py +4 -4
  39. rasa/shared/providers/_configs/self_hosted_llm_client_config.py +4 -4
  40. rasa/shared/providers/llm/_base_litellm_client.py +42 -14
  41. rasa/shared/providers/llm/litellm_router_llm_client.py +38 -15
  42. rasa/shared/providers/llm/self_hosted_llm_client.py +34 -32
  43. rasa/shared/utils/configs.py +5 -8
  44. rasa/utils/endpoints.py +6 -0
  45. rasa/version.py +1 -1
  46. {rasa_pro-3.15.0a1.dist-info → rasa_pro-3.15.0a3.dist-info}/METADATA +12 -12
  47. {rasa_pro-3.15.0a1.dist-info → rasa_pro-3.15.0a3.dist-info}/RECORD +50 -37
  48. {rasa_pro-3.15.0a1.dist-info → rasa_pro-3.15.0a3.dist-info}/NOTICE +0 -0
  49. {rasa_pro-3.15.0a1.dist-info → rasa_pro-3.15.0a3.dist-info}/WHEEL +0 -0
  50. {rasa_pro-3.15.0a1.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)
rasa/builder/service.py CHANGED
@@ -51,6 +51,7 @@ from rasa.builder.guardrails.constants import (
51
51
  from rasa.builder.guardrails.store import guardrails_store
52
52
  from rasa.builder.job_manager import job_manager
53
53
  from rasa.builder.jobs import (
54
+ run_backup_to_bot_job,
54
55
  run_prompt_to_bot_job,
55
56
  run_replace_all_files_job,
56
57
  run_template_to_bot_job,
@@ -70,6 +71,7 @@ from rasa.builder.models import (
70
71
  JobStatus,
71
72
  JobStatusEvent,
72
73
  PromptRequest,
74
+ RestoreFromBackupRequest,
73
75
  ServerSentEvent,
74
76
  TemplateRequest,
75
77
  )
@@ -466,6 +468,95 @@ async def handle_template_to_bot(request: Request) -> HTTPResponse:
466
468
  )
467
469
 
468
470
 
471
+ @bp.route("/backup-to-bot", methods=["POST"])
472
+ @openapi.summary("Generate bot from backup archive")
473
+ @openapi.description(
474
+ "Creates a complete conversational AI bot from a backup tar.gz archive. "
475
+ "Returns immediately with a job ID. Connect to `/job-events/<job_id>` to "
476
+ "receive server-sent events (SSE) for real-time progress tracking "
477
+ "throughout the bot restoration process.\n\n"
478
+ "**SSE Event Flow** (via `/job-events/<job_id>`):\n"
479
+ "1. `received` - Request received by server\n"
480
+ "2. `generating` - Extracting and restoring bot from backup\n"
481
+ "3. `generation_success` - Backup restoration completed successfully\n"
482
+ "4. `training` - Training the bot model (if no existing model found)\n"
483
+ "5. `train_success` - Model training completed (if training was needed)\n"
484
+ "6. `done` - Bot restoration completed\n\n"
485
+ "**Error Events:**\n"
486
+ "- `generation_error` - Failed to restore bot from backup\n"
487
+ "- `train_error` - Backup restored but training failed\n"
488
+ "- `validation_error` - Restored bot configuration is invalid\n"
489
+ "- `error` - Unexpected error occurred\n\n"
490
+ "**Usage:**\n"
491
+ "1. Send POST request with Content-Type: application/json\n"
492
+ "2. The response will be a JSON object `{job_id: ...}`\n"
493
+ "3. Connect to `/job-events/<job_id>` for a server-sent event stream of progress."
494
+ )
495
+ @openapi.tag("bot-generation")
496
+ @openapi.body(
497
+ {"application/json": model_to_schema(RestoreFromBackupRequest)},
498
+ description="Backup request with presigned URL to tar.gz archive.",
499
+ required=True,
500
+ example={"presigned_url": "https://s3.amazonaws.com/bucket/path?signature=..."},
501
+ )
502
+ @openapi.response(
503
+ 200,
504
+ {"application/json": model_to_schema(JobCreateResponse)},
505
+ description="Job created. Poll or subscribe to /job-events/<job_id> for progress.",
506
+ )
507
+ @openapi.response(
508
+ 400,
509
+ {"application/json": model_to_schema(ApiErrorResponse)},
510
+ description="Validation error in request payload or invalid presigned URL",
511
+ )
512
+ @openapi.response(
513
+ 500,
514
+ {"application/json": model_to_schema(ApiErrorResponse)},
515
+ description="Internal server error",
516
+ )
517
+ @openapi.parameter(
518
+ HEADER_USER_ID,
519
+ description=(
520
+ "Optional user id to associate requests (e.g., for telemetry/guardrails)."
521
+ ),
522
+ _in="header",
523
+ required=False,
524
+ schema=str,
525
+ )
526
+ async def handle_backup_to_bot(request: Request) -> HTTPResponse:
527
+ """Handle backup-to-bot restoration requests."""
528
+ try:
529
+ payload = RestoreFromBackupRequest(**request.json)
530
+ except Exception as exc:
531
+ return response.json(
532
+ ApiErrorResponse(
533
+ error="Invalid request", details={"error": str(exc)}
534
+ ).model_dump(),
535
+ status=400,
536
+ )
537
+
538
+ try:
539
+ # Allocate job and schedule background task
540
+ job = job_manager.create_job()
541
+ request.app.add_task(
542
+ run_backup_to_bot_job(request.app, job, payload.presigned_url)
543
+ )
544
+ return response.json(JobCreateResponse(job_id=job.id).model_dump(), status=200)
545
+ except Exception as exc:
546
+ capture_exception_with_context(
547
+ exc,
548
+ "bot_builder_service.backup_to_bot.unexpected_error",
549
+ tags={"endpoint": "/api/backup-to-bot"},
550
+ )
551
+ return response.json(
552
+ ApiErrorResponse(
553
+ error="Failed to create backup-to-bot job",
554
+ details={"error": str(exc)},
555
+ ).model_dump(),
556
+ status=HTTPStatus.INTERNAL_SERVER_ERROR,
557
+ )
558
+
559
+
469
560
  @bp.route("/files", methods=["GET"])
470
561
  @openapi.summary("Get bot files")
471
562
  @openapi.description(
@@ -801,16 +892,14 @@ async def get_bot_info(request: Request) -> HTTPResponse:
801
892
  @openapi.summary("Download bot project as tar.gz")
802
893
  @openapi.description(
803
894
  "Downloads the current bot project files as a compressed tar.gz archive. "
804
- "Includes all configuration files and a .env file with RASA_PRO_LICENSE. "
805
- "Requires valid JWT token in Authorization header."
895
+ "Includes all configuration files and a .env file with RASA_PRO_LICENSE."
806
896
  )
807
897
  @openapi.tag("bot-files")
808
898
  @openapi.parameter(
809
- "Authorization",
810
- description=("Bearer token for authentication. Always required for this endpoint."),
811
- _in="header",
812
- required=True,
813
- schema=str,
899
+ "exclude_models_directory",
900
+ bool,
901
+ location="query",
902
+ description="Whether to exclude the models directory",
814
903
  )
815
904
  @openapi.parameter(
816
905
  HEADER_USER_ID,
@@ -833,32 +922,25 @@ async def get_bot_info(request: Request) -> HTTPResponse:
833
922
  {"application/gzip": bytes},
834
923
  description="Bot project downloaded successfully as tar.gz",
835
924
  )
836
- @openapi.response(
837
- 401,
838
- {"application/json": model_to_schema(ApiErrorResponse)},
839
- description=(
840
- "Authentication failed - Authorization header missing or invalid. "
841
- "Authentication is always required for this endpoint."
842
- ),
843
- )
844
925
  @openapi.response(
845
926
  500,
846
927
  {"application/json": model_to_schema(ApiErrorResponse)},
847
928
  description="Internal server error",
848
929
  )
849
- @protected(always_required=True)
850
930
  async def download_bot_project(request: Request) -> HTTPResponse:
851
931
  """Download bot project as tar.gz archive."""
852
932
  try:
853
- # Token verification is enforced by the
854
- # protected(always_required=True) decorator.
933
+ # Get query parameters
934
+ exclude_models_directory = (
935
+ request.args.get("exclude_models_directory", "true").lower() == "true"
936
+ )
937
+ project_name = request.args.get("project_name", "bot-project")
855
938
 
856
939
  # Get bot files
857
940
  project_generator = get_project_generator(request)
858
- bot_files = project_generator.get_bot_files()
859
-
860
- # Get project name from query parameters, default to "bot-project"
861
- project_name = request.args.get("project_name", "bot-project")
941
+ bot_files = project_generator.get_bot_files(
942
+ exclude_models_directory=exclude_models_directory
943
+ )
862
944
 
863
945
  # Create tar.gz archive
864
946
  tar_data = create_bot_project_archive(bot_files, project_name)