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/config.py CHANGED
@@ -13,6 +13,10 @@ OPENAI_VECTOR_STORE_ID = os.getenv(
13
13
  )
14
14
  OPENAI_MAX_VECTOR_RESULTS = int(os.getenv("OPENAI_MAX_VECTOR_RESULTS", "10"))
15
15
  OPENAI_TIMEOUT = int(os.getenv("OPENAI_TIMEOUT", "30"))
16
+ # OpenAI Token Pricing Configuration (per 1,000 tokens)
17
+ COPILOT_INPUT_TOKEN_PRICE = float(os.getenv("COPILOT_INPUT_TOKEN_PRICE", "0.002"))
18
+ COPILOT_OUTPUT_TOKEN_PRICE = float(os.getenv("COPILOT_OUTPUT_TOKEN_PRICE", "0.0005"))
19
+ COPILOT_CACHED_TOKEN_PRICE = float(os.getenv("COPILOT_CACHED_TOKEN_PRICE", "0.002"))
16
20
 
17
21
  # Server Configuration
18
22
  BUILDER_SERVER_HOST = os.getenv("SERVER_HOST", "0.0.0.0")
@@ -0,0 +1,5 @@
1
+ # Security limits for backup archive extraction
2
+ MAX_BACKUP_SIZE = 100 * 1024 * 1024 # 100MB
3
+ MAX_ARCHIVE_FILE_SIZE = 100 * 1024 * 1024 # 100MB
4
+ MAX_ARCHIVE_TOTAL_SIZE = 500 * 1024 * 1024 # 500MB
5
+ MAX_ARCHIVE_FILES = 10000
@@ -42,6 +42,7 @@ from rasa.builder.exceptions import (
42
42
  DocumentRetrievalError,
43
43
  )
44
44
  from rasa.builder.shared.tracker_context import TrackerContext
45
+ from rasa.builder.telemetry.copilot_langfuse_telemetry import CopilotLangfuseTelemetry
45
46
  from rasa.shared.constants import PACKAGE_NAME
46
47
 
47
48
  structlogger = structlog.get_logger()
@@ -72,7 +73,11 @@ class Copilot:
72
73
  )
73
74
 
74
75
  # The final stream chunk includes usage statistics.
75
- self.usage_statistics = UsageStatistics()
76
+ self.usage_statistics = UsageStatistics(
77
+ input_token_price=config.COPILOT_INPUT_TOKEN_PRICE,
78
+ output_token_price=config.COPILOT_OUTPUT_TOKEN_PRICE,
79
+ cached_token_price=config.COPILOT_CACHED_TOKEN_PRICE,
80
+ )
76
81
 
77
82
  @asynccontextmanager
78
83
  async def _get_client(self) -> AsyncGenerator[openai.AsyncOpenAI, None]:
@@ -94,6 +99,16 @@ class Copilot:
94
99
  error=str(exc),
95
100
  )
96
101
 
102
+ @property
103
+ def llm_config(self) -> Dict[str, Any]:
104
+ """The LLM config used to generate the response."""
105
+ return {
106
+ "model": config.OPENAI_MODEL,
107
+ "temperature": config.OPENAI_TEMPERATURE,
108
+ "stream": True,
109
+ "stream_options": {"include_usage": True},
110
+ }
111
+
97
112
  async def search_rasa_documentation(
98
113
  self,
99
114
  context: CopilotContext,
@@ -108,7 +123,9 @@ class Copilot:
108
123
  """
109
124
  try:
110
125
  query = self._create_documentation_search_query(context)
111
- return await self._inkeep_document_retrieval.retrieve_documents(query)
126
+ documents = await self._inkeep_document_retrieval.retrieve_documents(query)
127
+ # TODO: Log documentation retrieval to Langfuse
128
+ return documents
112
129
  except DocumentRetrievalError as e:
113
130
  structlogger.error(
114
131
  "copilot.search_rasa_documentation.error",
@@ -145,11 +162,12 @@ class Copilot:
145
162
  Exception: If an unexpected error occurs.
146
163
  """
147
164
  relevant_documents = await self.search_rasa_documentation(context)
148
- messages = await self._build_messages(context, relevant_documents)
149
165
  tracker_event_attachments = self._extract_tracker_event_attachments(
150
166
  context.copilot_chat_history[-1]
151
167
  )
168
+ messages = await self._build_messages(context, relevant_documents)
152
169
 
170
+ # TODO: Delete this after Langfuse is implemented
153
171
  support_evidence = CopilotGenerationContext(
154
172
  relevant_documents=relevant_documents,
155
173
  system_message=messages[0],
@@ -163,6 +181,7 @@ class Copilot:
163
181
  support_evidence,
164
182
  )
165
183
 
184
+ @CopilotLangfuseTelemetry.trace_copilot_streaming_generation
166
185
  async def _stream_response(
167
186
  self, messages: List[Dict[str, Any]]
168
187
  ) -> AsyncGenerator[str, None]:
@@ -172,13 +191,10 @@ class Copilot:
172
191
  try:
173
192
  async with self._get_client() as client:
174
193
  stream = await client.chat.completions.create(
175
- model=config.OPENAI_MODEL,
176
- messages=messages, # type: ignore
177
- temperature=config.OPENAI_TEMPERATURE,
178
- stream=True,
179
- stream_options={"include_usage": True},
194
+ messages=messages,
195
+ **self.llm_config,
180
196
  )
181
- async for chunk in stream:
197
+ async for chunk in stream: # type: ignore[attr-defined]
182
198
  # The final chunk, which contains the usage statistics,
183
199
  # arrives with an empty `choices` list.
184
200
  if not chunk.choices:
@@ -189,6 +205,7 @@ class Copilot:
189
205
  delta = chunk.choices[0].delta
190
206
  if delta and delta.content:
191
207
  yield delta.content
208
+
192
209
  except openai.OpenAIError as e:
193
210
  structlogger.exception("copilot.stream_response.api_error", error=str(e))
194
211
  raise CopilotStreamError(
@@ -559,4 +576,6 @@ class Copilot:
559
576
  """Extract the tracker event attachments from the message."""
560
577
  if not isinstance(message, UserChatMessage):
561
578
  return []
579
+ # TODO: Log tracker event attachments to Langfuse only in the case of the
580
+ # User chat message.
562
581
  return message.get_content_blocks_by_type(EventContent)
@@ -3,6 +3,7 @@ from enum import Enum
3
3
  from typing import Any, Dict, List, Literal, Optional, Type, TypeVar, Union
4
4
 
5
5
  import structlog
6
+ from openai.types.chat import ChatCompletion
6
7
  from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
7
8
  from pydantic import (
8
9
  BaseModel,
@@ -343,6 +344,55 @@ ChatMessage = Union[
343
344
  ]
344
345
 
345
346
 
347
+ def create_chat_message_from_dict(message_data: Dict[str, Any]) -> ChatMessage:
348
+ """Parse a single chat message dictionary into a ChatMessage object.
349
+
350
+ This utility function manually parses a chat message dictionary into the
351
+ appropriate ChatMessage type based on its role field.
352
+
353
+ Args:
354
+ message_data: Dictionary containing chat message data
355
+
356
+ Returns:
357
+ Parsed ChatMessage object
358
+
359
+ Raises:
360
+ ValueError: If an unknown role is encountered
361
+
362
+ Example:
363
+ >>> message_data = {
364
+ ... "role": "user",
365
+ ... "content": [{"type": "text", "text": "Hello"}]
366
+ ... }
367
+ >>> message = parse_chat_message_from_dict(message_data)
368
+ >>> isinstance(message, UserChatMessage)
369
+ True
370
+ >>> message.role
371
+ 'user'
372
+ """
373
+ available_roles = [ROLE_USER, ROLE_COPILOT, ROLE_COPILOT_INTERNAL]
374
+ role = message_data.get("role")
375
+
376
+ if role == ROLE_USER:
377
+ return UserChatMessage(**message_data)
378
+ elif role == ROLE_COPILOT:
379
+ return CopilotChatMessage(**message_data)
380
+ elif role == ROLE_COPILOT_INTERNAL:
381
+ return InternalCopilotRequestChatMessage(**message_data)
382
+ else:
383
+ message = (
384
+ f"Unknown role '{role}' in chat message. "
385
+ f"Available roles are: {', '.join(available_roles)}."
386
+ )
387
+ structlogger.error(
388
+ "models.create_chat_message_from_dict.unknown_role",
389
+ event_info=message,
390
+ role=role,
391
+ available_roles=available_roles,
392
+ )
393
+ raise ValueError(message)
394
+
395
+
346
396
  class CopilotContext(BaseModel):
347
397
  """Model containing the context used by the copilot to generate a response."""
348
398
 
@@ -390,37 +440,40 @@ class CopilotRequest(BaseModel):
390
440
 
391
441
  @field_validator("copilot_chat_history", mode="before")
392
442
  @classmethod
393
- def parse_chat_history(cls, v: List[Dict[str, Any]]) -> List[ChatMessage]:
443
+ def parse_chat_history(
444
+ cls, v: Union[List[Dict[str, Any]], List[ChatMessage]]
445
+ ) -> List[ChatMessage]:
394
446
  """Manually parse chat history messages based on role field."""
447
+ # If already parsed ChatMessage objects, return them as-is
448
+ if (
449
+ v
450
+ and isinstance(v, list)
451
+ and all(isinstance(item, ChatMessage) for item in v)
452
+ ):
453
+ return v # type: ignore[return-value]
454
+
455
+ # Check for mixed types (some ChatMessage, some not)
456
+ if (
457
+ v
458
+ and isinstance(v, list)
459
+ and any(isinstance(item, ChatMessage) for item in v)
460
+ ):
461
+ message = (
462
+ "Mixed types in copilot_chat_history: cannot mix ChatMessage objects"
463
+ "with other types."
464
+ )
465
+ structlog.get_logger().error(
466
+ "copilot_request.parse_chat_history.mixed_types",
467
+ event_info=message,
468
+ chat_history_types=[type(item) for item in v],
469
+ )
470
+ raise ValueError(message)
471
+
472
+ # Otherwise, parse from dictionaries
395
473
  parsed_messages: List[ChatMessage] = []
396
- available_roles = [ROLE_USER, ROLE_COPILOT, ROLE_COPILOT_INTERNAL]
397
474
  for message_data in v:
398
- role = message_data.get("role")
399
-
400
- if role == ROLE_USER:
401
- parsed_messages.append(UserChatMessage(**message_data))
402
-
403
- elif role == ROLE_COPILOT:
404
- parsed_messages.append(CopilotChatMessage(**message_data))
405
-
406
- elif role == ROLE_COPILOT_INTERNAL:
407
- parsed_messages.append(
408
- InternalCopilotRequestChatMessage(**message_data)
409
- )
410
-
411
- else:
412
- message = (
413
- f"Unknown role '{role}' in chat message. "
414
- f"Available roles are: {', '.join(available_roles)}."
415
- )
416
- structlogger.error(
417
- "copilot_request.parse_chat_history.unknown_role",
418
- event_info=message,
419
- role=role,
420
- available_roles=available_roles,
421
- )
422
- raise ValueError(message)
423
-
475
+ chat_message = create_chat_message_from_dict(message_data)
476
+ parsed_messages.append(chat_message)
424
477
  return parsed_messages
425
478
 
426
479
  @property
@@ -612,16 +665,171 @@ class TrainingErrorLog(CopilotOutput):
612
665
 
613
666
 
614
667
  class UsageStatistics(BaseModel):
615
- prompt_tokens: Optional[int] = None
616
- completion_tokens: Optional[int] = None
617
- total_tokens: Optional[int] = None
618
- model: Optional[str] = None
668
+ """Usage statistics for a copilot generation."""
669
+
670
+ # Token usage statistics
671
+ prompt_tokens: Optional[int] = Field(
672
+ default=None,
673
+ description=(
674
+ "Total number of prompt tokens used to generate completion. "
675
+ "Should include cached prompt tokens."
676
+ ),
677
+ )
678
+ completion_tokens: Optional[int] = Field(
679
+ default=None,
680
+ description="Number of generated tokens.",
681
+ )
682
+ total_tokens: Optional[int] = Field(
683
+ default=None,
684
+ description="Total number of tokens used (input + output).",
685
+ )
686
+ cached_prompt_tokens: Optional[int] = Field(
687
+ default=None,
688
+ description="Number of cached prompt tokens.",
689
+ )
690
+ model: Optional[str] = Field(
691
+ default=None,
692
+ description="The model used to generate the response.",
693
+ )
694
+
695
+ # Token prices
696
+ input_token_price: float = Field(
697
+ default=0.0,
698
+ description="Price per 1K input tokens in dollars.",
699
+ )
700
+ output_token_price: float = Field(
701
+ default=0.0,
702
+ description="Price per 1K output tokens in dollars.",
703
+ )
704
+ cached_token_price: float = Field(
705
+ default=0.0,
706
+ description="Price per 1K cached tokens in dollars.",
707
+ )
708
+
709
+ @property
710
+ def non_cached_prompt_tokens(self) -> Optional[int]:
711
+ """Get the non-cached prompt tokens."""
712
+ if self.cached_prompt_tokens is not None and self.prompt_tokens is not None:
713
+ return self.prompt_tokens - self.cached_prompt_tokens
714
+ return self.prompt_tokens
715
+
716
+ @property
717
+ def non_cached_cost(self) -> Optional[float]:
718
+ """Calculate the non-cached token cost based on configured pricing."""
719
+ if self.non_cached_prompt_tokens is None:
720
+ return None
721
+ if self.non_cached_prompt_tokens == 0:
722
+ return 0.0
723
+
724
+ return (self.non_cached_prompt_tokens / 1000.0) * self.input_token_price
725
+
726
+ @property
727
+ def cached_cost(self) -> Optional[float]:
728
+ """Calculate the cached token cost based on configured pricing."""
729
+ if self.cached_prompt_tokens is None:
730
+ return None
731
+ if self.cached_prompt_tokens == 0:
732
+ return 0.0
733
+
734
+ return (self.cached_prompt_tokens / 1000.0) * self.cached_token_price
735
+
736
+ @property
737
+ def input_cost(self) -> Optional[float]:
738
+ """Calculate the input token cost based on configured pricing.
739
+
740
+ The calculation takes into account the cached prompt tokens (if available) too.
741
+ """
742
+ # If both non-cached and cached costs are None, there's no input cost
743
+ if self.non_cached_cost is None and self.cached_cost is None:
744
+ return None
745
+
746
+ # If only non-cached cost is available, return it
747
+ if self.non_cached_cost is not None and self.cached_cost is None:
748
+ return self.non_cached_cost
749
+
750
+ # If only cached cost is available, return it
751
+ if self.non_cached_cost is None and self.cached_cost is not None:
752
+ return self.cached_cost
753
+
754
+ # If both are available, return the sum
755
+ return self.non_cached_cost + self.cached_cost # type: ignore[operator]
756
+
757
+ @property
758
+ def output_cost(self) -> Optional[float]:
759
+ """Calculate the output token cost based on configured pricing."""
760
+ if self.completion_tokens is None:
761
+ return None
762
+ if self.completion_tokens == 0:
763
+ return 0.0
764
+
765
+ return (self.completion_tokens / 1000.0) * self.output_token_price
766
+
767
+ @property
768
+ def total_cost(self) -> Optional[float]:
769
+ """Calculate the total cost based on configured pricing.
770
+
771
+ Returns:
772
+ Total cost in dollars, or None if insufficient data.
773
+ """
774
+ if self.input_cost is None or self.output_cost is None:
775
+ return None
776
+
777
+ return self.input_cost + self.output_cost
778
+
779
+ def update_token_prices(
780
+ self,
781
+ input_token_price: float,
782
+ output_token_price: float,
783
+ cached_token_price: float,
784
+ ) -> None:
785
+ """Update token prices with provided values.
786
+
787
+ Args:
788
+ input_token_price: Price per 1K input tokens in dollars.
789
+ output_token_price: Price per 1K output tokens in dollars.
790
+ cached_token_price: Price per 1K cached tokens in dollars.
791
+ """
792
+ self.input_token_price = input_token_price
793
+ self.output_token_price = output_token_price
794
+ self.cached_token_price = cached_token_price
795
+
796
+ @classmethod
797
+ def from_chat_completion_response(
798
+ cls,
799
+ response: ChatCompletion,
800
+ input_token_price: float = 0.0,
801
+ output_token_price: float = 0.0,
802
+ cached_token_price: float = 0.0,
803
+ ) -> Optional["UsageStatistics"]:
804
+ """Create a UsageStatistics object from a ChatCompletionChunk."""
805
+ if not (usage := getattr(response, "usage", None)):
806
+ return None
807
+
808
+ usage_statistics = cls(
809
+ input_token_price=input_token_price,
810
+ output_token_price=output_token_price,
811
+ cached_token_price=cached_token_price,
812
+ )
813
+
814
+ usage_statistics.prompt_tokens = usage.prompt_tokens
815
+ usage_statistics.completion_tokens = usage.completion_tokens
816
+ usage_statistics.total_tokens = usage.total_tokens
817
+ usage_statistics.model = getattr(response, "model", None)
818
+
819
+ # Extract cached tokens if available
820
+ if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details:
821
+ usage_statistics.cached_prompt_tokens = getattr(
822
+ usage.prompt_tokens_details, "cached_tokens", None
823
+ )
824
+
825
+ return usage_statistics
619
826
 
620
827
  def reset(self) -> None:
621
828
  """Reset usage statistics to their default values."""
622
829
  self.prompt_tokens = None
623
830
  self.completion_tokens = None
624
831
  self.total_tokens = None
832
+ self.cached_prompt_tokens = None
625
833
  self.model = None
626
834
 
627
835
  def update_from_stream_chunk(self, chunk: ChatCompletionChunk) -> None:
@@ -630,14 +838,25 @@ class UsageStatistics(BaseModel):
630
838
  Args:
631
839
  chunk: The OpenAI stream chunk containing usage statistics.
632
840
  """
841
+ # Reset the usage statistics to their default values
842
+ self.reset()
843
+
844
+ # If the chunk has no usage statistics, return
633
845
  if not (usage := getattr(chunk, "usage", None)):
634
846
  return
635
847
 
848
+ # Update the usage statistics with the values from the chunk
636
849
  self.prompt_tokens = usage.prompt_tokens
637
850
  self.completion_tokens = usage.completion_tokens
638
851
  self.total_tokens = usage.total_tokens
639
852
  self.model = getattr(chunk, "model", None)
640
853
 
854
+ # Extract cached tokens if available
855
+ if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details:
856
+ self.cached_prompt_tokens = getattr(
857
+ usage.prompt_tokens_details, "cached_tokens", None
858
+ )
859
+
641
860
 
642
861
  class SigningContext(BaseModel):
643
862
  secret: Optional[str] = Field(None)
@@ -17,6 +17,7 @@ from rasa.builder.document_retrieval.constants import (
17
17
  )
18
18
  from rasa.builder.document_retrieval.models import Document
19
19
  from rasa.builder.exceptions import DocumentRetrievalError
20
+ from rasa.builder.telemetry.copilot_langfuse_telemetry import CopilotLangfuseTelemetry
20
21
  from rasa.shared.utils.io import read_json_file
21
22
 
22
23
  structlogger = structlog.get_logger()
@@ -88,6 +89,7 @@ class InKeepDocumentRetrieval:
88
89
  )
89
90
  raise e
90
91
 
92
+ @CopilotLangfuseTelemetry.trace_document_retrieval_generation
91
93
  async def _call_inkeep_rag_api(
92
94
  self, query: str, temperature: float, timeout: float
93
95
  ) -> ChatCompletion:
rasa/builder/download.py CHANGED
@@ -1,11 +1,21 @@
1
1
  """Download utilities for bot projects."""
2
2
 
3
+ import asyncio
3
4
  import io
4
5
  import os
5
6
  import sys
6
7
  import tarfile
8
+ import tempfile
9
+ from pathlib import Path
7
10
  from textwrap import dedent
8
11
  from typing import Dict, Optional
12
+ from urllib.parse import urlparse
13
+
14
+ import aiofiles
15
+ import aiohttp
16
+
17
+ from rasa.builder.constants import MAX_BACKUP_SIZE
18
+ from rasa.builder.exceptions import ProjectGenerationError
9
19
 
10
20
 
11
21
  def _get_env_content() -> str:
@@ -27,7 +37,7 @@ def _get_pyproject_toml_content(project_id: str) -> str:
27
37
  version = "0.1.0"
28
38
  description = "Add your description for your Rasa bot here"
29
39
  readme = "README.md"
30
- dependencies = ["rasa-pro>=3.13"]
40
+ dependencies = ["rasa-pro>=3.14"]
31
41
  requires-python = ">={sys.version_info.major}.{sys.version_info.minor}"
32
42
  """
33
43
  )
@@ -138,3 +148,103 @@ def create_bot_project_archive(
138
148
 
139
149
  tar_buffer.seek(0)
140
150
  return tar_buffer.getvalue()
151
+
152
+
153
+ def validate_s3_url(url: str) -> None:
154
+ """Validate that the URL is from an expected S3 domain for security.
155
+
156
+ Args:
157
+ url: The URL to validate
158
+
159
+ Raises:
160
+ ValueError: If the URL is not from an expected S3 domain
161
+ """
162
+ parsed = urlparse(url)
163
+ hostname = parsed.hostname
164
+
165
+ if not hostname:
166
+ raise ValueError("URL must have a valid hostname")
167
+
168
+ hostname = hostname.lower()
169
+ if not ("s3" in hostname and hostname.endswith(".amazonaws.com")):
170
+ raise ValueError(f"URL must be from an AWS S3 domain, got: {hostname}")
171
+
172
+
173
+ async def download_backup_from_url(url: str) -> str:
174
+ """Download backup file from presigned URL to a temporary file.
175
+
176
+ Args:
177
+ url: Presigned URL to download from
178
+
179
+ Returns:
180
+ Path to the downloaded temporary file
181
+
182
+ Raises:
183
+ ProjectGenerationError: If download fails or file is too large
184
+ """
185
+ # Validate URL for security
186
+ validate_s3_url(url)
187
+
188
+ # Create temporary file path (using mktemp for path only, not creating the file)
189
+ temp_file_fd, temp_file_path = tempfile.mkstemp(suffix=".tar.gz")
190
+ os.close(temp_file_fd) # Close the file descriptor immediately
191
+
192
+ try:
193
+ timeout = aiohttp.ClientTimeout(total=60)
194
+ async with aiohttp.ClientSession(timeout=timeout) as session:
195
+ async with session.get(url) as response:
196
+ if response.status != 200:
197
+ raise ProjectGenerationError(
198
+ f"Failed to download backup from presigned URL. "
199
+ f"HTTP {response.status}: {response.reason}",
200
+ attempts=1,
201
+ )
202
+
203
+ # Check content length if available
204
+ content_length = response.headers.get("Content-Length")
205
+ if content_length and int(content_length) > MAX_BACKUP_SIZE:
206
+ raise ProjectGenerationError(
207
+ f"Backup file too large "
208
+ f"({content_length} bytes > {MAX_BACKUP_SIZE} bytes). "
209
+ f"Please provide a smaller backup file.",
210
+ attempts=1,
211
+ )
212
+
213
+ # Stream download to file using async file operations
214
+ downloaded_size = 0
215
+ async with aiofiles.open(temp_file_path, "wb") as f:
216
+ async for chunk in response.content.iter_chunked(8192):
217
+ downloaded_size += len(chunk)
218
+
219
+ # Check size limit during download
220
+ if downloaded_size > MAX_BACKUP_SIZE:
221
+ raise ProjectGenerationError(
222
+ f"Backup file too large "
223
+ f"({downloaded_size} bytes > {MAX_BACKUP_SIZE} bytes).",
224
+ attempts=1,
225
+ )
226
+
227
+ await f.write(chunk)
228
+
229
+ return temp_file_path
230
+
231
+ except ProjectGenerationError:
232
+ # Clean up temp file and re-raise ProjectGenerationError as-is
233
+ try:
234
+ Path(temp_file_path).unlink(missing_ok=True)
235
+ except Exception:
236
+ pass
237
+ raise
238
+ except asyncio.TimeoutError:
239
+ error_message = "Download timeout: Presigned URL may have expired."
240
+ except aiohttp.ClientError as exc:
241
+ error_message = f"Network error downloading backup: {exc}"
242
+ except Exception as exc:
243
+ error_message = f"Unexpected error downloading backup: {exc}"
244
+
245
+ # Clean up temp file and raise error
246
+ try:
247
+ Path(temp_file_path).unlink(missing_ok=True)
248
+ except Exception:
249
+ pass
250
+ raise ProjectGenerationError(error_message, attempts=1)
File without changes
@@ -0,0 +1,15 @@
1
+ """Constants for the evaluator module."""
2
+
3
+ from pathlib import Path
4
+
5
+ # Base directory for the rasa package
6
+ BASE_DIR = Path(__file__).parent.parent.parent
7
+
8
+ # Response classification evaluation results directory
9
+ RESPONSE_CLASSIFICATION_EVALUATION_RESULTS_DIR = (
10
+ BASE_DIR / "builder" / "evaluator" / "results"
11
+ )
12
+ # Default output filename
13
+ DEFAULT_RESPONSE_CLASSIFICATION_EVALUATION_TEXT_OUTPUT_FILENAME = "run_results.txt"
14
+ # Default YAML output filename
15
+ RESPONSE_CLASSIFICATION_EVALUATION_YAML_OUTPUT_FILENAME = "run_results.yaml"