letschatty 0.4.305__py3-none-any.whl → 0.4.343__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.
Files changed (45) hide show
  1. letschatty/models/ai_microservices/expected_output.py +35 -1
  2. letschatty/models/ai_microservices/lambda_events.py +66 -5
  3. letschatty/models/ai_microservices/lambda_invokation_types.py +3 -0
  4. letschatty/models/analytics/events/__init__.py +1 -1
  5. letschatty/models/analytics/events/chat_based_events/chat_funnel.py +69 -13
  6. letschatty/models/analytics/events/company_based_events/asset_events.py +9 -2
  7. letschatty/models/analytics/events/event_type_to_classes.py +6 -3
  8. letschatty/models/analytics/events/event_types.py +13 -5
  9. letschatty/models/chat/chat.py +13 -2
  10. letschatty/models/chat/chat_with_assets.py +6 -1
  11. letschatty/models/chat/client.py +0 -2
  12. letschatty/models/chat/continuous_conversation.py +1 -1
  13. letschatty/models/company/CRM/funnel.py +365 -33
  14. letschatty/models/company/__init__.py +3 -1
  15. letschatty/models/company/assets/ai_agents_v2/ai_agent_message_draft.py +58 -0
  16. letschatty/models/company/assets/ai_agents_v2/chain_of_thought_in_chat.py +5 -3
  17. letschatty/models/company/assets/ai_agents_v2/chatty_ai_agent.py +30 -2
  18. letschatty/models/company/assets/ai_agents_v2/chatty_ai_agent_in_chat.py +91 -1
  19. letschatty/models/company/assets/ai_agents_v2/pre_qualify_config.py +111 -0
  20. letschatty/models/company/assets/ai_agents_v2/statuses.py +33 -0
  21. letschatty/models/company/assets/assignment/__init__.py +14 -0
  22. letschatty/models/company/assets/assignment/assignment_assets.py +75 -0
  23. letschatty/models/company/assets/automation.py +10 -1
  24. letschatty/models/company/assets/chat_assets.py +12 -2
  25. letschatty/models/company/assets/company_assets.py +3 -0
  26. letschatty/models/company/assets/launch/__init__.py +12 -0
  27. letschatty/models/company/assets/launch/launch.py +128 -0
  28. letschatty/models/company/assets/launch/scheduled_communication.py +44 -0
  29. letschatty/models/company/assets/launch/subscription.py +63 -0
  30. letschatty/models/company/assets/sale.py +3 -3
  31. letschatty/models/company/assets/users/user.py +5 -1
  32. letschatty/models/company/company_messaging_settgins.py +2 -1
  33. letschatty/models/company/form_field.py +182 -12
  34. letschatty/models/messages/chatty_messages/schema/chatty_content/content_document.py +4 -2
  35. letschatty/models/messages/chatty_messages/schema/chatty_content/content_media.py +4 -3
  36. letschatty/models/utils/custom_exceptions/custom_exceptions.py +24 -0
  37. letschatty/services/ai_agents/smart_follow_up_service_v2.py +10 -0
  38. letschatty/services/chat/chat_service.py +79 -14
  39. letschatty/services/factories/analytics/events_factory.py +5 -3
  40. letschatty/services/users/user_factory.py +14 -8
  41. letschatty/services/validators/analytics_validator.py +11 -0
  42. {letschatty-0.4.305.dist-info → letschatty-0.4.343.dist-info}/METADATA +2 -1
  43. {letschatty-0.4.305.dist-info → letschatty-0.4.343.dist-info}/RECORD +45 -36
  44. {letschatty-0.4.305.dist-info → letschatty-0.4.343.dist-info}/WHEEL +1 -1
  45. {letschatty-0.4.305.dist-info → letschatty-0.4.343.dist-info}/LICENSE +0 -0
@@ -5,7 +5,7 @@ from ...utils.types.identifier import StrObjectId
5
5
 
6
6
  class Sale(CompanyAssetModel):
7
7
  chat_id: StrObjectId
8
- product_id: StrObjectId
8
+ product_id: Optional[StrObjectId] = Field(default=None)
9
9
  quantity: int
10
10
  total_amount: float
11
11
  currency: str
@@ -16,7 +16,7 @@ class Sale(CompanyAssetModel):
16
16
  creator_id: StrObjectId
17
17
 
18
18
  class SaleRequest(BaseModel):
19
- product_id: StrObjectId
19
+ product_id: Optional[StrObjectId] = Field(default=None)
20
20
  quantity: int
21
21
  creator_id: StrObjectId
22
22
  total_amount: float
@@ -36,4 +36,4 @@ class SaleRequest(BaseModel):
36
36
  "paid_amount": 100,
37
37
  "installments": 1,
38
38
  "details": {}
39
- }
39
+ }
@@ -65,6 +65,7 @@ class UserPreview(ChattyAssetPreview):
65
65
  roles: List[UserRole]
66
66
  user_type : UserType
67
67
  email : Optional[str] = Field(default=None)
68
+ auto_assign_chats: bool = Field(default=False)
68
69
 
69
70
  @classmethod
70
71
  def not_found(cls, id: StrObjectId, company_id: StrObjectId) -> 'UserPreview':
@@ -80,6 +81,7 @@ class UserPreview(ChattyAssetPreview):
80
81
  photo_url="https://files-chatty.s3.us-east-1.amazonaws.com/Disen%CC%83o+sin+ti%CC%81tulo.png",
81
82
  roles=[],
82
83
  user_type=UserType.HUMAN,
84
+ auto_assign_chats=False
83
85
  )
84
86
 
85
87
  @classmethod
@@ -92,6 +94,7 @@ class UserPreview(ChattyAssetPreview):
92
94
  projection["email"] = 1
93
95
  projection["user_type"] = 1
94
96
  projection["api_key_expires_at"] = 1
97
+ projection["auto_assign_chats"] = 1
95
98
  return projection
96
99
 
97
100
  @classmethod
@@ -107,7 +110,8 @@ class UserPreview(ChattyAssetPreview):
107
110
  roles=asset.roles,
108
111
  user_type=asset.user_type,
109
112
  email=asset.email,
110
- updated_at=asset.updated_at
113
+ updated_at=asset.updated_at,
114
+ auto_assign_chats=asset.auto_assign_chats
111
115
  )
112
116
 
113
117
  class User(CompanyAssetModel):
@@ -13,4 +13,5 @@ class MessagingSettings(BaseModel):
13
13
  """Messaging settings for the company"""
14
14
  good_quality_score_definition: Optional[str] = Field(default=None, description="The definition of a good quality score")
15
15
  products_info_level : ProductsInfoLevel = Field(default=ProductsInfoLevel.NAME, description="Whether to include all products info in the prompt or just the name / description")
16
- ai_god_mode : Optional[ChattyAIMode] = Field(default=None, description="The mode of the ai god - null if not active")
16
+ ai_god_mode : Optional[ChattyAIMode] = Field(default=None, description="The mode of the ai god - null if not active")
17
+ tagger_instructions: Optional[str] = Field(default=None, description="The instructions for the tagger when using the tagger ai agent")
@@ -1,30 +1,88 @@
1
- from pydantic import BaseModel, Field
2
- from typing import Optional, Any
1
+ from pydantic import BaseModel, Field, field_validator, ConfigDict
2
+ from typing import Optional, Any, ClassVar, List
3
+ from letschatty.models.base_models.chatty_asset_model import CompanyAssetModel, ChattyAssetPreview
4
+ import re
3
5
 
4
6
 
5
- class FormField(BaseModel):
7
+ class FormFieldPreview(ChattyAssetPreview):
8
+ """Preview model for FormField - used in list views"""
9
+ field_key: str = Field(description="Unique key identifier for the field")
10
+ is_system_field: bool = Field(default=False, description="True if this is a system/standard field")
11
+ question_example: Optional[str] = Field(default=None, description="Example question for AI")
12
+ description: Optional[str] = Field(default=None, description="Description of the field")
13
+
14
+
15
+ class FormField(CompanyAssetModel):
6
16
  """
7
- Simple configuration for data collection fields.
17
+ Company asset for data collection fields.
8
18
  AI handles all validation - no regex or type constraints.
19
+
20
+ System fields (is_system_field=True) are standard fields that map directly
21
+ to Client model properties (name, email, phone, document_id, external_id).
22
+
23
+ When a system field is customized, a document is created in this same collection
24
+ with is_system_field=True and the same field_key as the base system field.
25
+ The field_key serves as the unique identifier for system field customizations.
26
+
27
+ field_key is FROZEN after creation - it cannot be changed.
9
28
  """
10
- field_name: str = Field(description="Unique identifier for the field (e.g., 'email', 'budget')")
29
+ name: str = Field(description="Display name of the field (e.g., 'Email', 'Budget')")
30
+ field_key: str = Field(description="Unique key identifier for the field (e.g., 'email', 'budget'). Only letters, numbers, and underscores allowed. Frozen after creation.")
11
31
  description: str = Field(
32
+ default="",
12
33
  description="Description of what information this field aims to collect"
13
34
  )
14
35
  question_example: Optional[str] = Field(
15
36
  default=None,
16
37
  description="Example of how AI should ask for this information"
17
38
  )
18
- required: bool = Field(
39
+ is_system_field: bool = Field(
19
40
  default=False,
20
- description="Whether this field is required"
41
+ description="True if this is a system/standard field (cannot be deleted)"
21
42
  )
22
43
 
44
+ @field_validator('field_key', mode='before')
45
+ @classmethod
46
+ def normalize_field_key(cls, v: str) -> str:
47
+ """
48
+ Normalize field_key to only contain lowercase letters, numbers, and underscores.
49
+ - Converts to lowercase
50
+ - Replaces spaces and hyphens with underscores
51
+ - Removes any other special characters
52
+ - Strips leading/trailing whitespace
53
+ """
54
+ if not v:
55
+ raise ValueError("field_key cannot be empty")
56
+
57
+ # Convert to lowercase and strip whitespace
58
+ normalized = v.lower().strip()
59
+
60
+ # Replace spaces and hyphens with underscores
61
+ normalized = normalized.replace(' ', '_').replace('-', '_')
62
+
63
+ # Remove any character that's not a letter, number, or underscore
64
+ normalized = re.sub(r'[^a-z0-9_]', '', normalized)
65
+
66
+ # Remove consecutive underscores
67
+ normalized = re.sub(r'_+', '_', normalized)
68
+
69
+ # Remove leading/trailing underscores
70
+ normalized = normalized.strip('_')
71
+
72
+ if not normalized:
73
+ raise ValueError("field_key must contain at least one letter or number")
74
+
75
+ return normalized
76
+
77
+ # Preview class for API responses
78
+ preview_class: ClassVar[type[FormFieldPreview]] = FormFieldPreview
79
+
23
80
  @classmethod
24
81
  def example_email(cls) -> dict:
25
82
  """Example email field"""
26
83
  return {
27
- "field_name": "email",
84
+ "name": "Email",
85
+ "field_key": "email",
28
86
  "description": "Customer's email address for communication",
29
87
  "question_example": "Could you share your email so I can send you more information?",
30
88
  "required": True
@@ -34,10 +92,115 @@ class FormField(BaseModel):
34
92
  def example_budget(cls) -> dict:
35
93
  """Example budget field"""
36
94
  return {
37
- "field_name": "budget",
95
+ "name": "Budget",
96
+ "field_key": "budget",
38
97
  "description": "Customer's budget allocation for the project",
39
- "question_example": "What's your budget range for this project?",
40
- "required": False
98
+ "question_example": "What's your budget range for this project?"
99
+ }
100
+
101
+
102
+ class SystemFormFields:
103
+ """
104
+ Standard system fields available for all companies.
105
+ These map directly to Client model properties.
106
+
107
+ field_key is the unique identifier for system fields.
108
+ """
109
+
110
+ # System field keys (used as identifiers)
111
+ SYSTEM_KEYS = ["name", "email", "phone", "document_id", "external_id"]
112
+
113
+ @classmethod
114
+ def get_all(cls) -> List[dict]:
115
+ """Get all system form fields as dicts (for API responses)"""
116
+ return [
117
+ cls.name_field(),
118
+ cls.email_field(),
119
+ cls.phone_field(),
120
+ cls.document_id_field(),
121
+ cls.external_id_field(),
122
+ ]
123
+
124
+ @classmethod
125
+ def get_all_keys(cls) -> List[str]:
126
+ """Get all system field keys"""
127
+ return cls.SYSTEM_KEYS
128
+
129
+ @classmethod
130
+ def is_system_field_key(cls, field_key: str) -> bool:
131
+ """Check if a field_key belongs to a system field"""
132
+ return field_key in cls.SYSTEM_KEYS
133
+
134
+ @classmethod
135
+ def get_by_key(cls, field_key: str) -> Optional[dict]:
136
+ """Get a system field by its field_key"""
137
+ fields_map = {
138
+ "name": cls.name_field(),
139
+ "email": cls.email_field(),
140
+ "phone": cls.phone_field(),
141
+ "document_id": cls.document_id_field(),
142
+ "external_id": cls.external_id_field(),
143
+ }
144
+ return fields_map.get(field_key)
145
+
146
+ @classmethod
147
+ def name_field(cls) -> dict:
148
+ """Standard name field"""
149
+ return {
150
+ "field_key": "name",
151
+ "name": "Nombre",
152
+ "description": "Nombre del cliente",
153
+ "question_example": "¿Cuál es tu nombre?",
154
+ "is_system_field": True,
155
+ "deleted_at": None
156
+ }
157
+
158
+ @classmethod
159
+ def email_field(cls) -> dict:
160
+ """Standard email field"""
161
+ return {
162
+ "field_key": "email",
163
+ "name": "Email",
164
+ "description": "Dirección de correo electrónico del cliente",
165
+ "question_example": "¿Podrías compartirme tu email para enviarte más información?",
166
+ "is_system_field": True,
167
+ "deleted_at": None
168
+ }
169
+
170
+ @classmethod
171
+ def phone_field(cls) -> dict:
172
+ """Standard phone field"""
173
+ return {
174
+ "field_key": "phone",
175
+ "name": "Teléfono",
176
+ "description": "Número de teléfono del cliente",
177
+ "question_example": "¿Cuál es tu número de teléfono?",
178
+ "is_system_field": True,
179
+ "deleted_at": None
180
+ }
181
+
182
+ @classmethod
183
+ def document_id_field(cls) -> dict:
184
+ """Standard document ID field (DNI/ID)"""
185
+ return {
186
+ "field_key": "document_id",
187
+ "name": "DNI / Documento",
188
+ "description": "Número de documento de identidad del cliente",
189
+ "question_example": "¿Cuál es tu número de DNI o documento?",
190
+ "is_system_field": True,
191
+ "deleted_at": None
192
+ }
193
+
194
+ @classmethod
195
+ def external_id_field(cls) -> dict:
196
+ """Standard external/CRM ID field"""
197
+ return {
198
+ "field_key": "external_id",
199
+ "name": "ID Externo / CRM",
200
+ "description": "Identificador externo del cliente en CRM u otro sistema",
201
+ "question_example": "¿Tienes un número de cliente o código de referencia?",
202
+ "is_system_field": True,
203
+ "deleted_at": None
41
204
  }
42
205
 
43
206
 
@@ -49,7 +212,11 @@ class CollectedData(BaseModel):
49
212
  name: Optional[str] = Field(default=None, description="Customer's name")
50
213
  email: Optional[str] = Field(default=None, description="Customer's email address")
51
214
  phone: Optional[str] = Field(default=None, description="Customer's phone number")
52
- document_id: Optional[str] = Field(default=None, description="Customer's DNI/ID number")
215
+ document_id: Optional[str] = Field(
216
+ default=None,
217
+ alias="dni",
218
+ description="Customer's DNI/ID number"
219
+ )
53
220
 
54
221
  # Generic key-value store for any other collected fields
55
222
  additional_fields: dict[str, Any] = Field(
@@ -57,6 +224,9 @@ class CollectedData(BaseModel):
57
224
  description="Additional collected fields as key-value pairs"
58
225
  )
59
226
 
227
+ model_config = ConfigDict(
228
+ populate_by_name=True
229
+ )
60
230
 
61
231
  @classmethod
62
232
  def example(cls) -> dict:
@@ -1,5 +1,6 @@
1
1
  from pydantic import BaseModel, Field, model_validator, ValidationInfo
2
2
  from typing import Optional
3
+ from urllib.parse import urlparse, unquote
3
4
  from .content_media import ChattyContentMedia
4
5
 
5
6
  class ChattyContentDocument(ChattyContentMedia):
@@ -8,5 +9,6 @@ class ChattyContentDocument(ChattyContentMedia):
8
9
  @model_validator(mode='before')
9
10
  def validate_filename(cls, data: dict, info: ValidationInfo):
10
11
  if not data.get("filename") and data.get("url"):
11
- data["filename"] = data["url"].split("/")[-1]
12
- return data
12
+ parsed = urlparse(data["url"])
13
+ data["filename"] = unquote(parsed.path.split("/")[-1])
14
+ return data
@@ -1,5 +1,6 @@
1
1
  from pydantic import BaseModel, Field, field_validator, HttpUrl
2
2
  from typing import Optional
3
+ from urllib.parse import quote
3
4
  class ChattyContentMedia(BaseModel):
4
5
  id: Optional[str] = Field(description="Unique identifier for the image. Also known as media_id", default="")
5
6
  url: str = Field(description="URL of the media from S3")
@@ -11,9 +12,9 @@ class ChattyContentMedia(BaseModel):
11
12
  def validate_url(cls, v):
12
13
  if not v:
13
14
  raise ValueError("URL is required")
14
- HttpUrl(v)
15
- return v
15
+ encoded = quote(str(v), safe=":/?&=%#")
16
+ HttpUrl(encoded)
17
+ return encoded
16
18
 
17
19
  def get_body_or_caption(self) -> str:
18
20
  return self.caption
19
-
@@ -59,6 +59,30 @@ class WhatsAppPayloadValidationError(Exception):
59
59
  def __init__(self, message="WhatsApp payload validation error", status_code=400, **context_data):
60
60
  super().__init__(message, status_code=status_code, **context_data)
61
61
 
62
+
63
+ class ChatWithActiveContinuousConversation(CustomException):
64
+ """
65
+ Raised when an AI agent is triggered on a chat that has an active Continuous Conversation.
66
+ """
67
+ def __init__(self, message="Chat has an active continuous conversation", status_code=400, **context_data):
68
+ super().__init__(message, status_code=status_code, **context_data)
69
+
70
+
71
+ class ChattyAIModeOff(CustomException):
72
+ """
73
+ Raised when an AI agent is in OFF mode and cannot be triggered.
74
+ """
75
+ def __init__(self, message="Chatty AI agent is OFF", status_code=400, **context_data):
76
+ super().__init__(message, status_code=status_code, **context_data)
77
+
78
+
79
+ class MissingAIAgentInChat(CustomException):
80
+ """
81
+ Raised when a chat has no AI agent assigned but one is required for the operation.
82
+ """
83
+ def __init__(self, message="AI agent not assigned to chat", status_code=404, **context_data):
84
+ super().__init__(message, status_code=status_code, **context_data)
85
+
62
86
  class UnsuportedChannel(CustomException):
63
87
  def __init__(self, message="Channel not supported", status_code=400, **context_data):
64
88
  super().__init__(message, status_code=status_code, **context_data)
@@ -1,5 +1,6 @@
1
1
  from typing import List
2
2
  from letschatty.models.chat.chat import Chat, FlowStateAssignedToChat
3
+ from letschatty.models.chat.flow_link_state import StateTrigger
3
4
  from letschatty.models.company.assets.ai_agents_v2.follow_up_strategy import FollowUpStrategy
4
5
  from letschatty.models.company.assets.ai_agents_v2.ai_agents_decision_output import SmartFollowUpDecision, SmartFollowUpDecisionAction
5
6
  from letschatty.services.chat.chat_service import ChatService
@@ -85,5 +86,14 @@ class SmartFollowUpService:
85
86
  SmartFollowUpService.validate_next_call_time_or_default(decision, smart_follow_up_state.consecutive_count + 1, follow_up_strategy)
86
87
  smart_follow_up_state.next_call = decision.next_call_time_value
87
88
  ChatService.update_workflow_link(chat=chat, workflow_id=smart_follow_up_state.flow_id, workflow_link=smart_follow_up_state, execution_context=execution_context)
89
+ elif decision.action == SmartFollowUpDecisionAction.POSTPONE_DELTA_TIME:
90
+ logger.debug(f"Postponing smart follow up for chat {chat.id} by delta time {decision.next_call_time_value}")
91
+ SmartFollowUpService.validate_next_call_time_or_default(decision, smart_follow_up_state.consecutive_count + 1, follow_up_strategy)
92
+ smart_follow_up_state.next_call = decision.next_call_time_value
93
+ ChatService.update_workflow_link(chat=chat, workflow_id=smart_follow_up_state.flow_id, workflow_link=smart_follow_up_state, execution_context=execution_context)
94
+ elif decision.action == SmartFollowUpDecisionAction.POSTPONE_TILL_UPDATE:
95
+ logger.debug(f"Postponing smart follow up for chat {chat.id} till update")
96
+ smart_follow_up_state.trigger = StateTrigger.CHAT_UPDATE
97
+ ChatService.update_workflow_link(chat=chat, workflow_id=smart_follow_up_state.flow_id, workflow_link=smart_follow_up_state, execution_context=execution_context)
88
98
  else:
89
99
  raise ValueError(f"Invalid action: {decision.action}")
@@ -28,9 +28,10 @@ from ...models.chat.scheduled_messages import ScheduledMessageStatus
28
28
  from ...models.utils.types.identifier import StrObjectId
29
29
  from ...models.utils.custom_exceptions.custom_exceptions import AssetAlreadyAssigned, MessageNotFoundError, NotFoundError, MessageAlreadyInChat, MetaErrorNotification, ChatAlreadyAssigned, AlreadyCompleted, ErrorToMantainSafety
30
30
  from ..factories.messages.central_notification_factory import CentralNotificationFactory
31
+ from ..factories.messages.chatty_message_factory import from_message_draft
31
32
  from ...models.messages.chatty_messages.base.message_draft import ChattyContentAudio, MessageDraft
32
33
  from ...models.messages.chatty_messages.schema.chatty_content.content_central import CentralNotificationStatus
33
- from ...models.messages.chatty_messages.schema import ChattyContext
34
+ from ...models.messages.chatty_messages.schema import ChattyContext, ChattyContentText
34
35
  from ...models.utils.types.message_types import MessageType
35
36
  from .conversation_topics_service import ConversationTopicsService
36
37
  import logging
@@ -211,6 +212,43 @@ class ChatService:
211
212
  ChatService.add_central_notification_from_text(chat=chat, body=f"Agente de IA {chatty_ai_agent.name} actualizado en el chat {chat.id} por {execution_context.executor.name}", subtype=MessageSubtype.CHATTY_AI_AGENT_UPDATED)
212
213
  return chat.chatty_ai_agent
213
214
 
215
+ @staticmethod
216
+ def escalate_chatty_ai_agent(
217
+ chat: Chat,
218
+ execution_context: ExecutionContext,
219
+ message: Optional[str] = None,
220
+ reason: Optional[str] = None
221
+ ) -> None:
222
+ """
223
+ Mark the chat's AI agent as requiring human intervention and add a central notification.
224
+ """
225
+ if chat.chatty_ai_agent and not chat.chatty_ai_agent.requires_human_intervention:
226
+ chat.chatty_ai_agent.requires_human_intervention = True
227
+ execution_context.set_event_time(datetime.now(tz=ZoneInfo("UTC")))
228
+ body = "El chat fue escalado a un agente humano"
229
+ if reason:
230
+ body = f"{body}. Motivo: {reason}"
231
+ ChatService.add_central_notification_from_text(
232
+ chat=chat,
233
+ body=body,
234
+ subtype=MessageSubtype.CHATTY_AI_AGENT_NOTIFICATION,
235
+ content_status=CentralNotificationStatus.WARNING,
236
+ context=ChattyContext(chain_of_thought_id=execution_context.chain_of_thought_id)
237
+ )
238
+ if message:
239
+ outgoing_context = ChattyContext(chain_of_thought_id=execution_context.chain_of_thought_id)
240
+ outgoing_message = from_message_draft(
241
+ MessageDraft(
242
+ type=MessageType.TEXT,
243
+ content=ChattyContentText(body=message),
244
+ context=outgoing_context,
245
+ subtype=MessageSubtype.NONE,
246
+ is_incoming_message=False
247
+ ),
248
+ sent_by=execution_context.executor.id
249
+ )
250
+ ChatService.add_message(chat=chat, message=outgoing_message)
251
+
214
252
  @staticmethod
215
253
  def add_workflow_link(chat : Chat, link : LinkItem, flow:FlowPreview, execution_context: ExecutionContext, description: str, last_incoming_message_id: Optional[str] = None, next_call: Optional[datetime] = None) -> FlowStateAssignedToChat:
216
254
  """
@@ -266,36 +304,46 @@ class ChatService:
266
304
  return next((state for state in chat.flow_states if state.is_smart_follow_up), None)
267
305
 
268
306
  @staticmethod
269
- def create_sale(chat : Chat, execution_context: ExecutionContext, sale : Sale, product : Product) -> SaleAssignedToChat:
307
+ def create_sale(
308
+ chat: Chat,
309
+ execution_context: ExecutionContext,
310
+ sale: Sale,
311
+ product: Optional[Product],
312
+ product_ids: Optional[List[StrObjectId]] = None,
313
+ product_label: Optional[str] = None
314
+ ) -> SaleAssignedToChat:
270
315
  """
271
316
  Add a sale to the chat.
272
317
  """
273
318
  if next((sale for sale in chat.client.sales if sale.asset_id == sale.id), None) is not None:
274
319
  raise AssetAlreadyAssigned(f"Sale with id {sale.id} already assigned to chat {chat.id}")
320
+ label = product_label or (product.name if product else "multiples productos")
321
+ assigned_product_ids = product_ids or ([product.id] if product else [])
275
322
  assigned_asset = SaleAssignedToChat(
276
323
  asset_type=ChatAssetType.SALE,
277
324
  asset_id=sale.id,
278
325
  assigned_at=sale.created_at,
279
326
  assigned_by=execution_context.executor.id,
280
- product_id=product.id
327
+ product_id=product.id if product else None,
328
+ product_ids=assigned_product_ids
281
329
  )
282
330
  execution_context.set_event_time(assigned_asset.assigned_at)
283
331
  bisect.insort(chat.client.sales, assigned_asset)
284
- ChatService.create_highlight(chat=chat, execution_context=execution_context, highlight_data=HighlightRequestData(title=f"🛍️ Venta de {product.name}", description=f"Venta de {product.name} creada por {execution_context.executor.name}", starred=False, subtype=MessageSubtype.SALE_ADDED))
285
- ChatService.add_central_notification_from_text(chat=chat, body=f"Venta de {product.name} agregada al chat {chat.id} por {execution_context.executor.name}", subtype=MessageSubtype.SALE_ADDED)
332
+ ChatService.create_highlight(chat=chat, execution_context=execution_context, highlight_data=HighlightRequestData(title=f"🛍️ Venta de {label}", description=f"Venta de {label} creada por {execution_context.executor.name}", starred=False, subtype=MessageSubtype.SALE_ADDED))
333
+ ChatService.add_central_notification_from_text(chat=chat, body=f"Venta de {label} agregada al chat {chat.id} por {execution_context.executor.name}", subtype=MessageSubtype.SALE_ADDED)
286
334
  return assigned_asset
287
335
 
288
336
  @staticmethod
289
- def update_sale(chat : Chat, execution_context: ExecutionContext, sale : Sale, product : Product) -> Sale:
337
+ def update_sale(chat: Chat, execution_context: ExecutionContext, sale: Sale, product_label: str) -> Sale:
290
338
  """
291
339
  Update a sale for the chat.
292
340
  """
293
- ChatService.create_highlight(chat=chat, execution_context=execution_context, highlight_data=HighlightRequestData(title=f"🛍️ Venta actualizada de {product.name}", description=f"Venta de {product.name} actualizada por {execution_context.executor.name}", starred=False, subtype=MessageSubtype.SALE_UPDATED))
294
- ChatService.add_central_notification_from_text(chat=chat, body=f"Venta de {product.name} actualizada en el chat {chat.id} por {execution_context.executor.name}", subtype=MessageSubtype.SALE_UPDATED)
341
+ ChatService.create_highlight(chat=chat, execution_context=execution_context, highlight_data=HighlightRequestData(title=f"🛍️ Venta actualizada de {product_label}", description=f"Venta de {product_label} actualizada por {execution_context.executor.name}", starred=False, subtype=MessageSubtype.SALE_UPDATED))
342
+ ChatService.add_central_notification_from_text(chat=chat, body=f"Venta de {product_label} actualizada en el chat {chat.id} por {execution_context.executor.name}", subtype=MessageSubtype.SALE_UPDATED)
295
343
  return sale
296
344
 
297
345
  @staticmethod
298
- def delete_sale(chat : Chat, execution_context: ExecutionContext, sale_id : StrObjectId, product : Product) -> SaleAssignedToChat:
346
+ def delete_sale(chat: Chat, execution_context: ExecutionContext, sale_id: StrObjectId, product_label: str) -> SaleAssignedToChat:
299
347
  """
300
348
  Logically remove a sale from the chat.
301
349
  """
@@ -303,8 +351,8 @@ class ChatService:
303
351
  assigned_asset_to_remove = next(sale for sale in chat.client.sales if sale.asset_id == sale_id)
304
352
  chat.client.sales.remove(assigned_asset_to_remove)
305
353
  execution_context.set_event_time(datetime.now(tz=ZoneInfo("UTC")))
306
- ChatService.create_highlight(chat=chat, execution_context=execution_context, highlight_data=HighlightRequestData(title=f"🛍️ Venta eliminada de {product.name}", description=f"Venta de {product.name} eliminada por {execution_context.executor.name}", starred=False, subtype=MessageSubtype.SALE_DELETED))
307
- ChatService.add_central_notification_from_text(chat=chat, body=f"Venta de {product.name} eliminada del chat {chat.id} por {execution_context.executor.name}", subtype=MessageSubtype.SALE_DELETED)
354
+ ChatService.create_highlight(chat=chat, execution_context=execution_context, highlight_data=HighlightRequestData(title=f"🛍️ Venta eliminada de {product_label}", description=f"Venta de {product_label} eliminada por {execution_context.executor.name}", starred=False, subtype=MessageSubtype.SALE_DELETED))
355
+ ChatService.add_central_notification_from_text(chat=chat, body=f"Venta de {product_label} eliminada del chat {chat.id} por {execution_context.executor.name}", subtype=MessageSubtype.SALE_DELETED)
308
356
  return assigned_asset_to_remove
309
357
  except StopIteration:
310
358
  raise NotFoundError(message=f"Sale with id {sale_id} not found in chat {chat.id}")
@@ -663,7 +711,10 @@ class ChatService:
663
711
  if client_data.external_id is not None:
664
712
  chat.client.external_id = client_data.external_id
665
713
  if client_data.lead_form_data is not None:
666
- chat.client.lead_form_data = client_data.lead_form_data
714
+ # Merge with existing lead_form_data instead of replacing
715
+ if chat.client.lead_form_data is None:
716
+ chat.client.lead_form_data = {}
717
+ chat.client.lead_form_data.update(client_data.lead_form_data)
667
718
  execution_context.set_event_time(datetime.now(tz=ZoneInfo("UTC")))
668
719
  ChatService.add_central_notification_from_text(chat=chat, body=f"La info del cliente fue actualizada por {execution_context.executor.name}", subtype=MessageSubtype.CLIENT_INFO_UPDATED)
669
720
  return chat
@@ -846,6 +897,13 @@ class ChatService:
846
897
  chat.client.email = collected_data.email
847
898
  updated_fields.append("email")
848
899
 
900
+ if collected_data.phone:
901
+ if chat.client.lead_form_data is None:
902
+ chat.client.lead_form_data = {}
903
+ if chat.client.lead_form_data.get("phone") != collected_data.phone:
904
+ chat.client.lead_form_data["phone"] = collected_data.phone
905
+ updated_fields.append("phone")
906
+
849
907
  if collected_data.document_id and chat.client.document_id != collected_data.document_id:
850
908
  chat.client.document_id = collected_data.document_id
851
909
  updated_fields.append("document_id")
@@ -863,11 +921,18 @@ class ChatService:
863
921
  execution_context.set_event_time(datetime.now(tz=ZoneInfo("UTC")))
864
922
  logger.info(f"Updated collected data for chat {chat.id}: {', '.join(updated_fields)}")
865
923
 
924
+ field_label_map = {
925
+ "name": "nombre",
926
+ "email": "email",
927
+ "phone": "telefono",
928
+ "document_id": "dni",
929
+ }
930
+ display_fields = [field_label_map.get(field, field) for field in updated_fields]
866
931
  ChatService.add_central_notification_from_text(
867
932
  chat=chat,
868
- body=f"Collected customer data: {', '.join(updated_fields)}",
933
+ body=f"Datos del cliente recopilados: {', '.join(display_fields)}",
869
934
  subtype=MessageSubtype.CLIENT_INFO_UPDATED,
870
935
  context=ChattyContext(chain_of_thought_id=execution_context.chain_of_thought_id)
871
936
  )
872
937
 
873
- return chat.client.lead_form_data
938
+ return chat.client.lead_form_data
@@ -321,7 +321,7 @@ class EventFactory:
321
321
  return events
322
322
 
323
323
  @staticmethod
324
- def funnel_stage_events(chat_with_assets: ChatWithAssets, company_info: EmpresaModel, trace_id: str,executor_type: ExecutorType, executor_id: StrObjectId, chat_funnel: ClientFunnel, funnel_transition: StageTransition, event_type: EventType, time: datetime, company_snapshot: Optional[CompanyChatsSnapshot] = None, agent_snapshot: Optional[AgentChatsSnapshot] = None) -> List[Event]:
324
+ def funnel_stage_events(chat_with_assets: ChatWithAssets, company_info: EmpresaModel, trace_id: str,executor_type: ExecutorType, executor_id: StrObjectId, chat_funnel: ChatFunnel, funnel_transition: StageTransition, event_type: EventType, time: datetime, company_snapshot: Optional[CompanyChatsSnapshot] = None, agent_snapshot: Optional[AgentChatsSnapshot] = None) -> List[Event]:
325
325
  events = []
326
326
  base_data = EventFactory._create_base_customer_event_data(
327
327
  chat_with_assets=chat_with_assets,
@@ -334,8 +334,10 @@ class EventFactory:
334
334
  funnel_data = FunnelEventData(
335
335
  **base_data.model_dump(),
336
336
  funnel_id=chat_funnel.funnel_id,
337
- funnel_stage_transition=funnel_transition,
338
- time_in_funnel_seconds=chat_funnel.time_in_funnel_seconds
337
+ chat_funnel_id=chat_funnel.id,
338
+ stage_transition=funnel_transition,
339
+ time_in_funnel_seconds=chat_funnel.time_in_funnel_seconds,
340
+ time_in_last_stage_seconds=chat_funnel.time_in_current_stage_seconds
339
341
  )
340
342
  event = ChatFunnelEvent(
341
343
  specversion=EventFactory.package_version(),
@@ -129,11 +129,17 @@ class UserFactory:
129
129
 
130
130
  @staticmethod
131
131
  def create_updated_user(user: User, user_update_info: UserUpdateInfo) -> User:
132
- user_copy = User(**user.model_dump_json())
133
- user_copy.name = user_update_info.name if user_update_info.name else user.name
134
- user_copy.email = user_update_info.email if user_update_info.email else user.email
135
- user_copy.phone_number = user_update_info.phone_number if user_update_info.phone_number else user.phone_number
136
- user_copy.photo_url = user_update_info.photo_url if user_update_info.photo_url else user.photo_url
137
- user_copy.notifications = user_update_info.notifications if user_update_info.notifications else user.notifications
138
- user_copy.auto_assign_chats = user_update_info.auto_assign_chats if user_update_info.auto_assign_chats is not None else user.auto_assign_chats
139
- return user_copy
132
+ update_data = {}
133
+ if user_update_info.name:
134
+ update_data['name'] = user_update_info.name
135
+ if user_update_info.email:
136
+ update_data['email'] = user_update_info.email
137
+ if user_update_info.phone_number:
138
+ update_data['phone_number'] = user_update_info.phone_number
139
+ if user_update_info.photo_url:
140
+ update_data['photo_url'] = user_update_info.photo_url
141
+ if user_update_info.notifications:
142
+ update_data['notifications'] = user_update_info.notifications
143
+ if user_update_info.auto_assign_chats is not None:
144
+ update_data['auto_assign_chats'] = user_update_info.auto_assign_chats
145
+ return user.update_from_dict(**update_data)
@@ -10,6 +10,10 @@ if TYPE_CHECKING:
10
10
 
11
11
  class SourcesAndTopicsValidator:
12
12
  """This class provides methods to validate sources and topics."""
13
+ @staticmethod
14
+ def normalize_source_name(name: str) -> str:
15
+ return name.strip().lower()
16
+
13
17
  @staticmethod
14
18
  def source_validation_check(sources : Dict[str,Source], topics : Dict[str,Topic], source : Source, existing_source_id : str | None = None) -> None:
15
19
  """Checks validation of a source against other sources and topics."""
@@ -28,9 +32,16 @@ class SourcesAndTopicsValidator:
28
32
  """This method checks that no duplicated source is created.
29
33
  For Pure Ads, it checks that the ad_id is unique and doesn't exist already.
30
34
  For Other Sources, it checks that exact trigger doesn't exist already. """
35
+ normalized_name = SourcesAndTopicsValidator.normalize_source_name(source.name)
36
+ existing_source_name = None
37
+ if existing_source_id and existing_source_id in sources:
38
+ existing_source_name = SourcesAndTopicsValidator.normalize_source_name(sources[existing_source_id].name)
39
+ should_check_name = existing_source_name is None or normalized_name != existing_source_name
31
40
  for source_id, source_to_check in sources.items():
32
41
  if source_id == existing_source_id:
33
42
  continue
43
+ if should_check_name and SourcesAndTopicsValidator.normalize_source_name(source_to_check.name) == normalized_name:
44
+ raise ConflictedSource(f":warning: *Conflict while adding source: Name already exists* \n New source '{source.name}' \n- Id {source.id} \n Existing source '{source_to_check.name}' \n- Id {source_to_check.id}", conflicting_source_id=source_to_check.id)
34
45
  if source == source_to_check: #type: ignore
35
46
  if isinstance(source, OtherSource):
36
47
  raise ConflictedSource(f":warning: *Conflict while adding source: Trigger already exists* \n New source '{source.name}' \n- Id {source.id} \n- Trigger {source.trigger[:30]} \n Existing source '{source_to_check.name}' \n- Id {source_to_check.id} \n- Trigger '{source_to_check.trigger[:30]}'", conflicting_source_id=source_to_check.id)