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.
- rasa/builder/constants.py +5 -0
- rasa/builder/copilot/models.py +80 -28
- rasa/builder/download.py +110 -0
- rasa/builder/evaluator/__init__.py +0 -0
- rasa/builder/evaluator/constants.py +15 -0
- rasa/builder/evaluator/copilot_executor.py +89 -0
- rasa/builder/evaluator/dataset/models.py +173 -0
- rasa/builder/evaluator/exceptions.py +4 -0
- rasa/builder/evaluator/response_classification/__init__.py +0 -0
- rasa/builder/evaluator/response_classification/constants.py +66 -0
- rasa/builder/evaluator/response_classification/evaluator.py +346 -0
- rasa/builder/evaluator/response_classification/langfuse_runner.py +463 -0
- rasa/builder/evaluator/response_classification/models.py +61 -0
- rasa/builder/evaluator/scripts/__init__.py +0 -0
- rasa/builder/evaluator/scripts/run_response_classification_evaluator.py +152 -0
- rasa/builder/jobs.py +208 -1
- rasa/builder/logging_utils.py +25 -24
- rasa/builder/main.py +6 -1
- rasa/builder/models.py +23 -0
- rasa/builder/project_generator.py +29 -10
- rasa/builder/service.py +104 -22
- rasa/builder/training_service.py +13 -1
- rasa/builder/validation_service.py +2 -1
- rasa/core/actions/action_clean_stack.py +32 -0
- rasa/core/actions/constants.py +4 -0
- rasa/core/actions/custom_action_executor.py +70 -12
- rasa/core/actions/grpc_custom_action_executor.py +41 -2
- rasa/core/actions/http_custom_action_executor.py +49 -25
- rasa/core/channels/voice_stream/voice_channel.py +14 -2
- rasa/dialogue_understanding/generator/llm_based_command_generator.py +6 -3
- rasa/dialogue_understanding/generator/single_step/compact_llm_command_generator.py +15 -7
- rasa/dialogue_understanding/generator/single_step/search_ready_llm_command_generator.py +15 -8
- rasa/dialogue_understanding/processor/command_processor.py +49 -7
- rasa/shared/providers/_configs/azure_openai_client_config.py +4 -5
- rasa/shared/providers/_configs/default_litellm_client_config.py +4 -4
- rasa/shared/providers/_configs/litellm_router_client_config.py +3 -2
- rasa/shared/providers/_configs/openai_client_config.py +5 -7
- rasa/shared/providers/_configs/rasa_llm_client_config.py +4 -4
- rasa/shared/providers/_configs/self_hosted_llm_client_config.py +4 -4
- rasa/shared/providers/llm/_base_litellm_client.py +42 -14
- rasa/shared/providers/llm/litellm_router_llm_client.py +38 -15
- rasa/shared/providers/llm/self_hosted_llm_client.py +34 -32
- rasa/shared/utils/configs.py +5 -8
- rasa/utils/endpoints.py +6 -0
- rasa/version.py +1 -1
- {rasa_pro-3.15.0a1.dist-info → rasa_pro-3.15.0a3.dist-info}/METADATA +12 -12
- {rasa_pro-3.15.0a1.dist-info → rasa_pro-3.15.0a3.dist-info}/RECORD +50 -37
- {rasa_pro-3.15.0a1.dist-info → rasa_pro-3.15.0a3.dist-info}/NOTICE +0 -0
- {rasa_pro-3.15.0a1.dist-info → rasa_pro-3.15.0a3.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
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
|
rasa/builder/logging_utils.py
CHANGED
|
@@ -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
|
-
#
|
|
24
|
-
_validation_logs =
|
|
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:
|
|
46
|
-
) ->
|
|
47
|
-
"""Structlog processor that captures validation logs in
|
|
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
|
|
61
|
-
|
|
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
|
-
|
|
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
|
|
75
|
+
"""Context manager to capture validation logs using context variables.
|
|
71
76
|
|
|
72
|
-
This context manager
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
#
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
92
|
+
yield logs
|
|
90
93
|
finally:
|
|
91
|
-
#
|
|
92
|
-
|
|
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=[
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
"
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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
|
-
#
|
|
854
|
-
|
|
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
|
-
|
|
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)
|