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.
- letschatty/models/ai_microservices/expected_output.py +35 -1
- letschatty/models/ai_microservices/lambda_events.py +66 -5
- letschatty/models/ai_microservices/lambda_invokation_types.py +3 -0
- letschatty/models/analytics/events/__init__.py +1 -1
- letschatty/models/analytics/events/chat_based_events/chat_funnel.py +69 -13
- letschatty/models/analytics/events/company_based_events/asset_events.py +9 -2
- letschatty/models/analytics/events/event_type_to_classes.py +6 -3
- letschatty/models/analytics/events/event_types.py +13 -5
- letschatty/models/chat/chat.py +13 -2
- letschatty/models/chat/chat_with_assets.py +6 -1
- letschatty/models/chat/client.py +0 -2
- letschatty/models/chat/continuous_conversation.py +1 -1
- letschatty/models/company/CRM/funnel.py +365 -33
- letschatty/models/company/__init__.py +3 -1
- letschatty/models/company/assets/ai_agents_v2/ai_agent_message_draft.py +58 -0
- letschatty/models/company/assets/ai_agents_v2/chain_of_thought_in_chat.py +5 -3
- letschatty/models/company/assets/ai_agents_v2/chatty_ai_agent.py +30 -2
- letschatty/models/company/assets/ai_agents_v2/chatty_ai_agent_in_chat.py +91 -1
- letschatty/models/company/assets/ai_agents_v2/pre_qualify_config.py +111 -0
- letschatty/models/company/assets/ai_agents_v2/statuses.py +33 -0
- letschatty/models/company/assets/assignment/__init__.py +14 -0
- letschatty/models/company/assets/assignment/assignment_assets.py +75 -0
- letschatty/models/company/assets/automation.py +10 -1
- letschatty/models/company/assets/chat_assets.py +12 -2
- letschatty/models/company/assets/company_assets.py +3 -0
- letschatty/models/company/assets/launch/__init__.py +12 -0
- letschatty/models/company/assets/launch/launch.py +128 -0
- letschatty/models/company/assets/launch/scheduled_communication.py +44 -0
- letschatty/models/company/assets/launch/subscription.py +63 -0
- letschatty/models/company/assets/sale.py +3 -3
- letschatty/models/company/assets/users/user.py +5 -1
- letschatty/models/company/company_messaging_settgins.py +2 -1
- letschatty/models/company/form_field.py +182 -12
- letschatty/models/messages/chatty_messages/schema/chatty_content/content_document.py +4 -2
- letschatty/models/messages/chatty_messages/schema/chatty_content/content_media.py +4 -3
- letschatty/models/utils/custom_exceptions/custom_exceptions.py +24 -0
- letschatty/services/ai_agents/smart_follow_up_service_v2.py +10 -0
- letschatty/services/chat/chat_service.py +79 -14
- letschatty/services/factories/analytics/events_factory.py +5 -3
- letschatty/services/users/user_factory.py +14 -8
- letschatty/services/validators/analytics_validator.py +11 -0
- {letschatty-0.4.305.dist-info → letschatty-0.4.343.dist-info}/METADATA +2 -1
- {letschatty-0.4.305.dist-info → letschatty-0.4.343.dist-info}/RECORD +45 -36
- {letschatty-0.4.305.dist-info → letschatty-0.4.343.dist-info}/WHEEL +1 -1
- {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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
+
is_system_field: bool = Field(
|
|
19
40
|
default=False,
|
|
20
|
-
description="
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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(
|
|
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 {
|
|
285
|
-
ChatService.add_central_notification_from_text(chat=chat, body=f"Venta de {
|
|
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
|
|
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 {
|
|
294
|
-
ChatService.add_central_notification_from_text(chat=chat, body=f"Venta de {
|
|
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
|
|
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 {
|
|
307
|
-
ChatService.add_central_notification_from_text(chat=chat, body=f"Venta de {
|
|
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
|
-
|
|
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"
|
|
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:
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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)
|