letschatty 0.4.337__py3-none-any.whl → 0.4.338__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/__init__.py +3 -3
- letschatty/models/ai_microservices/expected_output.py +2 -29
- letschatty/models/ai_microservices/lambda_events.py +39 -135
- letschatty/models/ai_microservices/lambda_invokation_types.py +1 -3
- letschatty/models/ai_microservices/n8n_ai_agents_payload.py +1 -3
- letschatty/models/analytics/events/__init__.py +2 -3
- 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 +9 -50
- letschatty/models/chat/chat.py +2 -0
- 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 +2 -1
- letschatty/models/company/assets/ai_agents_v2/ai_agents_decision_output.py +1 -1
- letschatty/models/company/assets/ai_agents_v2/chain_of_thought_in_chat.py +3 -5
- letschatty/models/company/assets/ai_agents_v2/chatty_ai_agent_in_chat.py +2 -37
- letschatty/models/company/assets/ai_agents_v2/chatty_ai_mode.py +2 -2
- letschatty/models/company/assets/ai_agents_v2/get_chat_with_prompt_response.py +0 -1
- letschatty/models/company/assets/ai_agents_v2/pre_qualify_config.py +2 -14
- letschatty/models/company/assets/ai_agents_v2/statuses.py +33 -0
- letschatty/models/company/assets/automation.py +10 -19
- letschatty/models/company/assets/chat_assets.py +9 -0
- letschatty/models/company/assets/company_assets.py +2 -0
- letschatty/models/company/company_shopify_integration.py +10 -0
- letschatty/models/company/form_field.py +9 -2
- letschatty/models/data_base/collection_interface.py +29 -101
- letschatty/models/data_base/mongo_connection.py +9 -92
- 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 +1 -14
- letschatty/services/ai_agents/smart_follow_up_context_builder_v2.py +2 -5
- letschatty/services/chat/chat_service.py +8 -1
- letschatty/services/chatty_assets/__init__.py +0 -12
- letschatty/services/chatty_assets/asset_service.py +13 -190
- letschatty/services/chatty_assets/base_container.py +2 -3
- letschatty/services/chatty_assets/base_container_with_collection.py +26 -35
- letschatty/services/continuous_conversation_service/continuous_conversation_helper.py +0 -11
- letschatty/services/events/events_manager.py +1 -218
- letschatty/services/factories/analytics/events_factory.py +6 -66
- letschatty/services/factories/lambda_ai_orchestrartor/lambda_events_factory.py +8 -25
- letschatty/services/messages_helpers/get_caption_or_body_or_preview.py +4 -6
- {letschatty-0.4.337.dist-info → letschatty-0.4.338.dist-info}/METADATA +1 -1
- {letschatty-0.4.337.dist-info → letschatty-0.4.338.dist-info}/RECORD +48 -82
- letschatty/models/analytics/events/chat_based_events/ai_agent_execution_event.py +0 -71
- letschatty/services/chatty_assets/assets_collections.py +0 -137
- letschatty/services/chatty_assets/collections/__init__.py +0 -38
- letschatty/services/chatty_assets/collections/ai_agent_collection.py +0 -19
- letschatty/services/chatty_assets/collections/ai_agent_in_chat_collection.py +0 -32
- letschatty/services/chatty_assets/collections/ai_component_collection.py +0 -21
- letschatty/services/chatty_assets/collections/chain_of_thought_collection.py +0 -30
- letschatty/services/chatty_assets/collections/chat_collection.py +0 -21
- letschatty/services/chatty_assets/collections/contact_point_collection.py +0 -21
- letschatty/services/chatty_assets/collections/fast_answer_collection.py +0 -21
- letschatty/services/chatty_assets/collections/filter_criteria_collection.py +0 -18
- letschatty/services/chatty_assets/collections/flow_collection.py +0 -20
- letschatty/services/chatty_assets/collections/product_collection.py +0 -20
- letschatty/services/chatty_assets/collections/sale_collection.py +0 -20
- letschatty/services/chatty_assets/collections/source_collection.py +0 -21
- letschatty/services/chatty_assets/collections/tag_collection.py +0 -19
- letschatty/services/chatty_assets/collections/topic_collection.py +0 -21
- letschatty/services/chatty_assets/collections/user_collection.py +0 -20
- letschatty/services/chatty_assets/example_usage.py +0 -44
- letschatty/services/chatty_assets/services/__init__.py +0 -37
- letschatty/services/chatty_assets/services/ai_agent_in_chat_service.py +0 -73
- letschatty/services/chatty_assets/services/ai_agent_service.py +0 -23
- letschatty/services/chatty_assets/services/chain_of_thought_service.py +0 -70
- letschatty/services/chatty_assets/services/chat_service.py +0 -25
- letschatty/services/chatty_assets/services/contact_point_service.py +0 -29
- letschatty/services/chatty_assets/services/fast_answer_service.py +0 -32
- letschatty/services/chatty_assets/services/filter_criteria_service.py +0 -30
- letschatty/services/chatty_assets/services/flow_service.py +0 -25
- letschatty/services/chatty_assets/services/product_service.py +0 -30
- letschatty/services/chatty_assets/services/sale_service.py +0 -25
- letschatty/services/chatty_assets/services/source_service.py +0 -28
- letschatty/services/chatty_assets/services/tag_service.py +0 -32
- letschatty/services/chatty_assets/services/topic_service.py +0 -31
- letschatty/services/chatty_assets/services/user_service.py +0 -32
- letschatty/services/events/__init__.py +0 -6
- letschatty/services/factories/analytics/ai_agent_event_factory.py +0 -161
- {letschatty-0.4.337.dist-info → letschatty-0.4.338.dist-info}/LICENSE +0 -0
- {letschatty-0.4.337.dist-info → letschatty-0.4.338.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DataCollectionStatus(StrEnum):
|
|
5
|
+
"""
|
|
6
|
+
Status of data collection for the AI agent in this chat.
|
|
7
|
+
Only tracks field collection, not qualification status.
|
|
8
|
+
|
|
9
|
+
- COLLECTING: Still collecting data from the user
|
|
10
|
+
- MANDATORY_COMPLETED: All mandatory fields have been collected
|
|
11
|
+
- ALL_COMPLETED: All fields (mandatory + optional) have been collected
|
|
12
|
+
- CANCELLED: Data collection was cancelled
|
|
13
|
+
"""
|
|
14
|
+
COLLECTING = "collecting"
|
|
15
|
+
MANDATORY_COMPLETED = "mandatory_completed"
|
|
16
|
+
ALL_COMPLETED = "all_completed"
|
|
17
|
+
CANCELLED = "cancelled"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PreQualifyStatus(StrEnum):
|
|
21
|
+
"""
|
|
22
|
+
Status of pre-qualification for the AI agent in this chat.
|
|
23
|
+
Separate from data collection - tracks qualification evaluation.
|
|
24
|
+
|
|
25
|
+
- PENDING: Waiting for data collection to complete
|
|
26
|
+
- EVALUATING: Data collected, evaluating acceptance criteria
|
|
27
|
+
- QUALIFIED: User met acceptance criteria
|
|
28
|
+
- UNQUALIFIED: User did NOT meet acceptance criteria
|
|
29
|
+
"""
|
|
30
|
+
PENDING = "pending"
|
|
31
|
+
EVALUATING = "evaluating"
|
|
32
|
+
QUALIFIED = "qualified"
|
|
33
|
+
UNQUALIFIED = "unqualified"
|
|
@@ -14,27 +14,18 @@ class Automation(BaseModel):
|
|
|
14
14
|
chatty_ai_agent_config: Optional[ChattyAIConfigForAutomation] = Field(default=None)
|
|
15
15
|
area: Optional[Area] = Field(default=None)
|
|
16
16
|
agent_id: Optional[StrObjectId] = Field(default=None)
|
|
17
|
-
chain_of_thought
|
|
17
|
+
chain_of_thought: Optional[str] = Field(default=None)
|
|
18
|
+
# Funnel transition automations
|
|
19
|
+
target_funnel_id: Optional[StrObjectId] = Field(
|
|
20
|
+
default=None,
|
|
21
|
+
description="Target funnel to move the chat to (for cross-funnel transitions)"
|
|
22
|
+
)
|
|
23
|
+
target_stage_id: Optional[StrObjectId] = Field(
|
|
24
|
+
default=None,
|
|
25
|
+
description="Target stage within the target funnel"
|
|
26
|
+
)
|
|
18
27
|
# client_info: Optional[ClientInfo] = Field(default=None) me gustaría que levante el mail y/o otros atributos
|
|
19
28
|
|
|
20
|
-
@property
|
|
21
|
-
def has_automation(self) -> bool:
|
|
22
|
-
"""
|
|
23
|
-
Check if there's an actual automation defined (tags, products, or flow).
|
|
24
|
-
|
|
25
|
-
Returns:
|
|
26
|
-
bool: True if there's at least one tag, product, or flow defined
|
|
27
|
-
"""
|
|
28
|
-
return (
|
|
29
|
-
len(self.tags) > 0 or
|
|
30
|
-
len(self.products) > 0 or
|
|
31
|
-
len(self.flow) > 0 or
|
|
32
|
-
self.quality_score is not None or
|
|
33
|
-
self.chatty_ai_agent_config is not None or
|
|
34
|
-
self.area is not None or
|
|
35
|
-
self.highlight_description is not None
|
|
36
|
-
)
|
|
37
|
-
|
|
38
29
|
@model_validator(mode='after')
|
|
39
30
|
def check_agent_id(self):
|
|
40
31
|
if self.area == Area.WITH_AGENT and not self.agent_id:
|
|
@@ -4,6 +4,7 @@ from enum import StrEnum
|
|
|
4
4
|
from pydantic_core.core_schema import str_schema
|
|
5
5
|
|
|
6
6
|
from letschatty.models.company.assets.ai_agents_v2.chain_of_thought_in_chat import ChainOfThoughtInChatTrigger
|
|
7
|
+
from letschatty.models.company.assets.ai_agents_v2.statuses import DataCollectionStatus, PreQualifyStatus
|
|
7
8
|
from ...utils.types.identifier import StrObjectId
|
|
8
9
|
from datetime import datetime
|
|
9
10
|
from zoneinfo import ZoneInfo
|
|
@@ -75,6 +76,14 @@ class ChattyAIAgentAssignedToChat(AssignedAssetToChat):
|
|
|
75
76
|
mode: ChattyAIMode = Field(default=ChattyAIMode.OFF)
|
|
76
77
|
requires_human_intervention: bool = Field(default=False)
|
|
77
78
|
is_processing: bool = Field(default=False)
|
|
79
|
+
data_collection_status: Optional[DataCollectionStatus] = Field(
|
|
80
|
+
default=None,
|
|
81
|
+
description="Status of data collection for pre-qualification"
|
|
82
|
+
)
|
|
83
|
+
pre_qualify_status: Optional[PreQualifyStatus] = Field(
|
|
84
|
+
default=None,
|
|
85
|
+
description="Status of pre-qualification"
|
|
86
|
+
)
|
|
78
87
|
last_call_started_at: Optional[datetime] = Field(default=None, description="The timestamp of the get chat with prompt (the moment n8n started processing the call)")
|
|
79
88
|
trigger_timestamp: Optional[datetime] = Field(default=None, description="The timestamp of the trigger that started the call, if it's a manual trigger, it will be the timestamp of the manual trigger, if it's a follow up, it will be the timestamp of the follow up, if it's a user message, it will be the timestamp of the user message")
|
|
80
89
|
last_call_cot_id: Optional[StrObjectId] = Field(default=None)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from zoneinfo import ZoneInfo
|
|
2
|
+
from pydantic import Field, BaseModel
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ShopifyIntegration(BaseModel):
|
|
7
|
+
"""Shopify integration for the company"""
|
|
8
|
+
shopify_store_url: str = Field(default = "")
|
|
9
|
+
oauth_state: str = Field(default = "")
|
|
10
|
+
oauth_state_at: datetime = Field(default = datetime.now(tz=ZoneInfo("UTC")))
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from pydantic import BaseModel, Field, field_validator
|
|
1
|
+
from pydantic import BaseModel, Field, field_validator, ConfigDict
|
|
2
2
|
from typing import Optional, Any, ClassVar, List
|
|
3
3
|
from letschatty.models.base_models.chatty_asset_model import CompanyAssetModel, ChattyAssetPreview
|
|
4
4
|
import re
|
|
@@ -212,7 +212,11 @@ class CollectedData(BaseModel):
|
|
|
212
212
|
name: Optional[str] = Field(default=None, description="Customer's name")
|
|
213
213
|
email: Optional[str] = Field(default=None, description="Customer's email address")
|
|
214
214
|
phone: Optional[str] = Field(default=None, description="Customer's phone number")
|
|
215
|
-
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
|
+
)
|
|
216
220
|
|
|
217
221
|
# Generic key-value store for any other collected fields
|
|
218
222
|
additional_fields: dict[str, Any] = Field(
|
|
@@ -220,6 +224,9 @@ class CollectedData(BaseModel):
|
|
|
220
224
|
description="Additional collected fields as key-value pairs"
|
|
221
225
|
)
|
|
222
226
|
|
|
227
|
+
model_config = ConfigDict(
|
|
228
|
+
populate_by_name=True
|
|
229
|
+
)
|
|
223
230
|
|
|
224
231
|
@classmethod
|
|
225
232
|
def example(cls) -> dict:
|
|
@@ -4,7 +4,6 @@ from typing import TYPE_CHECKING, Dict, List, Generic, TypeVar, Type, Optional,
|
|
|
4
4
|
from bson.objectid import ObjectId
|
|
5
5
|
from pymongo.collection import Collection
|
|
6
6
|
from pymongo.database import Database
|
|
7
|
-
from motor.motor_asyncio import AsyncIOMotorDatabase, AsyncIOMotorCollection
|
|
8
7
|
|
|
9
8
|
from letschatty.models.chat.chat import Chat
|
|
10
9
|
from ...models.base_models.chatty_asset_model import ChattyAssetModel, CompanyAssetModel, ChattyAssetPreview
|
|
@@ -27,116 +26,58 @@ P = TypeVar('P', bound=ChattyAssetPreview)
|
|
|
27
26
|
class ChattyAssetCollectionInterface(Generic[T, P], ABC):
|
|
28
27
|
def __init__(self, database: str, collection: str, connection: MongoConnection, type: Type[T], preview_type: Optional[Type[P]] = None):
|
|
29
28
|
logger.info(f"Initializing collection {collection} in database {database}")
|
|
30
|
-
# Sync database and collection (existing)
|
|
31
29
|
self.db: Database = connection.client[database]
|
|
32
30
|
self.collection: Collection = connection.client[database][collection]
|
|
33
|
-
|
|
34
|
-
# NEW: Async database and collection
|
|
35
|
-
# Store connection reference to ensure we use current event loop
|
|
36
|
-
self._connection = connection
|
|
37
|
-
self._database_name = database
|
|
38
|
-
self._collection_name = collection
|
|
39
|
-
self._async_db: Optional[AsyncIOMotorDatabase] = None
|
|
40
|
-
self._async_collection: Optional[AsyncIOMotorCollection] = None
|
|
41
|
-
|
|
42
31
|
self.type = type
|
|
43
32
|
self.preview_type = preview_type
|
|
44
|
-
|
|
45
|
-
@property
|
|
46
|
-
def async_db(self) -> AsyncIOMotorDatabase:
|
|
47
|
-
"""Get async database, ensuring it uses the current event loop"""
|
|
48
|
-
# Always ensure connection's async client is using current loop (for Lambda compatibility)
|
|
49
|
-
self._connection._ensure_async_client_loop()
|
|
50
|
-
# Recreate database reference to ensure it uses the current client
|
|
51
|
-
self._async_db = self._connection.async_client[self._database_name]
|
|
52
|
-
return self._async_db
|
|
53
|
-
|
|
54
|
-
@property
|
|
55
|
-
def async_collection(self) -> AsyncIOMotorCollection:
|
|
56
|
-
"""Get async collection, ensuring it uses the current event loop"""
|
|
57
|
-
# Always ensure connection's async client is using current loop (for Lambda compatibility)
|
|
58
|
-
self._connection._ensure_async_client_loop()
|
|
59
|
-
# Recreate collection reference to ensure it uses the current client
|
|
60
|
-
self._async_collection = self._connection.async_client[self._database_name][self._collection_name]
|
|
61
|
-
return self._async_collection
|
|
62
33
|
@abstractmethod
|
|
63
34
|
def create_instance(self, data: dict) -> T:
|
|
64
35
|
"""Factory method to create instance from data"""
|
|
65
36
|
pass
|
|
66
37
|
|
|
67
|
-
|
|
68
|
-
async def insert(self, asset: T) -> StrObjectId:
|
|
69
|
-
"""Async insert operation"""
|
|
38
|
+
def insert(self, asset: T) -> StrObjectId:
|
|
70
39
|
if not isinstance(asset, self.type):
|
|
71
40
|
raise ValueError(f"Asset must be of type {self.type.__name__}")
|
|
72
41
|
document = asset.model_dump_json(serializer=SerializerType.DATABASE)
|
|
73
42
|
logger.debug(f"Inserting document: {document}")
|
|
74
|
-
result =
|
|
43
|
+
result = self.collection.insert_one(document)
|
|
75
44
|
if not result.inserted_id:
|
|
76
45
|
raise Exception("Failed to insert document")
|
|
77
46
|
logger.debug(f"Inserted document with id {result.inserted_id}")
|
|
78
47
|
return result.inserted_id
|
|
79
48
|
|
|
80
|
-
|
|
81
|
-
"""Async update operation"""
|
|
49
|
+
def update(self, asset: T) -> StrObjectId:
|
|
82
50
|
logger.debug(f"Updating document with id {asset.id}")
|
|
83
51
|
if not isinstance(asset, self.type):
|
|
84
52
|
raise ValueError(f"Asset must be of type {self.type.__name__}")
|
|
85
53
|
asset.update_now()
|
|
86
54
|
document = asset.model_dump_json(serializer=SerializerType.DATABASE)
|
|
87
|
-
document.pop('_id', None)
|
|
88
|
-
result =
|
|
89
|
-
{"_id": ObjectId(asset.id)},
|
|
90
|
-
{"$set": document}
|
|
91
|
-
)
|
|
55
|
+
document.pop('_id', None) # Still needed
|
|
56
|
+
result = self.collection.update_one({"_id": ObjectId(asset.id)}, {"$set": document})
|
|
92
57
|
if result.matched_count == 0:
|
|
93
58
|
raise NotFoundError(f"No document found with id {asset.id}")
|
|
94
59
|
if result.modified_count == 0:
|
|
95
60
|
logger.debug(f"No changes were made to the document with id {asset.id} probably because the values were the same")
|
|
96
61
|
return asset.id
|
|
97
62
|
|
|
98
|
-
|
|
99
|
-
"
|
|
100
|
-
|
|
101
|
-
|
|
63
|
+
def get_by_id(self, doc_id: str) -> T:
|
|
64
|
+
logger.debug(f"Getting document with id {doc_id} from collection {self.collection.name} and db {self.db.name}")
|
|
65
|
+
doc = self.collection.find_one({"_id": ObjectId(doc_id)})
|
|
66
|
+
|
|
102
67
|
if doc:
|
|
103
68
|
return self.create_instance(doc)
|
|
104
69
|
else:
|
|
105
|
-
raise NotFoundError(f"No document found with id {doc_id} in collection")
|
|
70
|
+
raise NotFoundError(f"No document found with id {doc_id} in db collection {self.collection.name} and db {self.db.name}")
|
|
106
71
|
|
|
107
|
-
|
|
108
|
-
"
|
|
109
|
-
logger.debug(f"Getting documents from collection with company_id {company_id} and query {query}")
|
|
72
|
+
def get_docs(self, company_id:Optional[StrObjectId], query = {}, limit = 0) -> List[T]:
|
|
73
|
+
logger.debug(f"Getting documents from collection {self.collection.name} with company_id {company_id} and query {query}")
|
|
110
74
|
if company_id:
|
|
111
|
-
query = query.copy()
|
|
75
|
+
query = query.copy() # Create a copy to avoid modifying the original
|
|
112
76
|
query["company_id"] = company_id
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
cursor = cursor.limit(limit)
|
|
116
|
-
docs = await cursor.to_list(length=limit if limit > 0 else None)
|
|
117
|
-
logger.debug(f"Found {len(docs)} documents")
|
|
77
|
+
docs = list(self.collection.find(filter=query).limit(limit))
|
|
78
|
+
logger.debug(f"Found {len(docs)} documents in collection {self.collection.name}")
|
|
118
79
|
return [self.create_instance(doc) for doc in docs]
|
|
119
80
|
|
|
120
|
-
async def delete(self, doc_id: str, deletion_type: DeletionType = DeletionType.LOGICAL) -> StrObjectId:
|
|
121
|
-
"""Delete operation"""
|
|
122
|
-
logger.debug(f"Deleting document with id {doc_id} - deletion type: {deletion_type}")
|
|
123
|
-
if deletion_type == DeletionType.LOGICAL:
|
|
124
|
-
result = await self.async_collection.update_one(
|
|
125
|
-
{"_id": ObjectId(doc_id)},
|
|
126
|
-
{"$set": {"deleted_at": datetime.now(ZoneInfo("UTC")), "updated_at": datetime.now(ZoneInfo("UTC"))}}
|
|
127
|
-
)
|
|
128
|
-
if result.modified_count == 0:
|
|
129
|
-
raise NotFoundError(f"No document found with id {doc_id}")
|
|
130
|
-
return doc_id
|
|
131
|
-
elif deletion_type == DeletionType.PHYSICAL:
|
|
132
|
-
result = await self.async_collection.delete_one({"_id": ObjectId(doc_id)})
|
|
133
|
-
if result.deleted_count == 0:
|
|
134
|
-
raise NotFoundError(f"No document found with id {doc_id}")
|
|
135
|
-
return doc_id
|
|
136
|
-
else:
|
|
137
|
-
raise ValueError(f"Invalid deletion type: {deletion_type}")
|
|
138
|
-
|
|
139
|
-
# Additional methods - keeping these sync as they're less critical
|
|
140
81
|
def get_preview_docs(self, projection = {}, all=True) -> List[P]:
|
|
141
82
|
"""We get the previews of all the documents in the collection for all companies"""
|
|
142
83
|
if not self.preview_type:
|
|
@@ -157,31 +98,18 @@ class ChattyAssetCollectionInterface(Generic[T, P], ABC):
|
|
|
157
98
|
docs = self.collection.find(query)
|
|
158
99
|
return [self.create_instance(doc) for doc in docs]
|
|
159
100
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
object_ids = [ObjectId(id) for id in ids]
|
|
175
|
-
|
|
176
|
-
# Query for all filter criteria with matching IDs
|
|
177
|
-
query = {
|
|
178
|
-
"_id": {"$in": object_ids},
|
|
179
|
-
"deleted_at": None
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
# Use the sync collection directly (inherited from ChattyAssetCollectionInterface)
|
|
183
|
-
docs = await self.async_collection.find(query).to_list(length=None)
|
|
184
|
-
|
|
185
|
-
# Create FilterCriteria instances
|
|
186
|
-
return [self.create_instance(doc) for doc in docs]
|
|
101
|
+
def delete(self, doc_id: str, deletion_type : DeletionType = DeletionType.LOGICAL) -> StrObjectId:
|
|
102
|
+
logger.debug(f"Deleting document with id {doc_id} - deletion type: {deletion_type}")
|
|
103
|
+
if deletion_type == DeletionType.LOGICAL:
|
|
104
|
+
result = self.collection.update_one({"_id": ObjectId(doc_id)}, {"$set": {"deleted_at": datetime.now(ZoneInfo("UTC")), "updated_at": datetime.now(ZoneInfo("UTC"))}})
|
|
105
|
+
if result.modified_count == 0:
|
|
106
|
+
raise NotFoundError(f"No document found with id {doc_id}")
|
|
107
|
+
return doc_id
|
|
108
|
+
elif deletion_type == DeletionType.PHYSICAL:
|
|
109
|
+
result = self.collection.delete_one({"_id": ObjectId(doc_id)})
|
|
110
|
+
if result.deleted_count == 0:
|
|
111
|
+
raise NotFoundError(f"No document found with id {doc_id}")
|
|
112
|
+
return doc_id
|
|
113
|
+
else:
|
|
114
|
+
raise ValueError(f"Invalid deletion type: {deletion_type}")
|
|
187
115
|
|
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
from ..base_models.singleton import SingletonMeta
|
|
2
2
|
from pymongo import MongoClient
|
|
3
|
-
from motor.motor_asyncio import AsyncIOMotorClient
|
|
4
3
|
from typing import Optional
|
|
5
|
-
from urllib.parse import quote_plus
|
|
6
4
|
import os
|
|
7
5
|
import atexit
|
|
8
|
-
import asyncio
|
|
9
|
-
import logging
|
|
10
|
-
|
|
11
|
-
logger = logging.getLogger(__name__)
|
|
12
6
|
|
|
13
7
|
class MongoConnection(metaclass=SingletonMeta):
|
|
14
8
|
def __init__(
|
|
@@ -16,103 +10,26 @@ class MongoConnection(metaclass=SingletonMeta):
|
|
|
16
10
|
username: Optional[str] = None,
|
|
17
11
|
password: Optional[str] = None,
|
|
18
12
|
uri_base: Optional[str] = None,
|
|
19
|
-
instance: Optional[str] = None
|
|
20
|
-
verify_on_init: bool = True
|
|
13
|
+
instance: Optional[str] = None
|
|
21
14
|
):
|
|
22
15
|
self.username = username or os.getenv('MONGO_USERNAME')
|
|
23
16
|
self.password = password or os.getenv('MONGO_PASSWORD')
|
|
24
17
|
self.uri_base = uri_base or os.getenv('MONGO_URI_BASE')
|
|
25
18
|
self.instance = instance or os.getenv('MONGO_INSTANCE_COMPONENT')
|
|
26
|
-
|
|
19
|
+
|
|
27
20
|
if not all([self.username, self.password, self.uri_base, self.instance]):
|
|
28
21
|
raise ValueError("Missing required MongoDB connection parameters")
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
encoded_username = quote_plus(self.username)
|
|
32
|
-
encoded_password = quote_plus(self.password)
|
|
33
|
-
|
|
34
|
-
uri = f"{self.uri_base}://{encoded_username}:{encoded_password}@{self.instance}.mongodb.net"
|
|
35
|
-
|
|
36
|
-
# Sync client (existing)
|
|
22
|
+
|
|
23
|
+
uri = f"{self.uri_base}://{self.username}:{self.password}@{self.instance}.mongodb.net"
|
|
37
24
|
self.client = MongoClient(uri)
|
|
38
|
-
|
|
39
|
-
# NEW: Async client for async operations
|
|
40
|
-
# Don't pass io_loop - Motor will automatically use the current event loop
|
|
41
|
-
# This is important for Lambda where the event loop changes between invocations
|
|
42
|
-
self.async_client = AsyncIOMotorClient(uri)
|
|
43
|
-
|
|
44
|
-
# Verify connections if requested
|
|
45
|
-
if verify_on_init:
|
|
46
|
-
try:
|
|
47
|
-
# Try to get running loop
|
|
48
|
-
loop = asyncio.get_running_loop()
|
|
49
|
-
# If we get here, there's a running loop
|
|
50
|
-
logger.warning(
|
|
51
|
-
"Event loop is already running. Skipping connection verification in __init__. "
|
|
52
|
-
"Call verify_connection_async() from async context to verify connection."
|
|
53
|
-
)
|
|
54
|
-
self._connection_verified = False
|
|
55
|
-
except RuntimeError:
|
|
56
|
-
# No running loop, safe to use run_until_complete
|
|
57
|
-
try:
|
|
58
|
-
# Test sync client
|
|
59
|
-
self.client.admin.command('ping')
|
|
60
|
-
|
|
61
|
-
# Test async client in sync context
|
|
62
|
-
loop = asyncio.new_event_loop()
|
|
63
|
-
asyncio.set_event_loop(loop)
|
|
64
|
-
loop.run_until_complete(self.async_client.admin.command('ping'))
|
|
65
|
-
self._connection_verified = True
|
|
66
|
-
loop.close()
|
|
67
|
-
except Exception as e:
|
|
68
|
-
self.client.close()
|
|
69
|
-
self.async_client.close()
|
|
70
|
-
raise ConnectionError(f"Failed to connect to MongoDB: {str(e)}")
|
|
71
|
-
else:
|
|
72
|
-
self._connection_verified = False
|
|
73
|
-
|
|
74
|
-
atexit.register(self.close)
|
|
75
|
-
|
|
76
|
-
def _ensure_async_client_loop(self):
|
|
77
|
-
"""Ensure async client is using the current event loop (for Lambda compatibility)"""
|
|
78
25
|
try:
|
|
79
|
-
|
|
80
|
-
# Check if client's loop is closed or different
|
|
81
|
-
client_loop = getattr(self.async_client, '_io_loop', None)
|
|
82
|
-
if client_loop is not None:
|
|
83
|
-
try:
|
|
84
|
-
# Try to check if the loop is closed
|
|
85
|
-
if client_loop.is_closed():
|
|
86
|
-
# Recreate client with current loop
|
|
87
|
-
logger.warning("Async client's event loop is closed, recreating client")
|
|
88
|
-
old_client = self.async_client
|
|
89
|
-
uri = f"{self.uri_base}://{quote_plus(self.username)}:{quote_plus(self.password)}@{self.instance}.mongodb.net"
|
|
90
|
-
self.async_client = AsyncIOMotorClient(uri)
|
|
91
|
-
try:
|
|
92
|
-
old_client.close()
|
|
93
|
-
except:
|
|
94
|
-
pass
|
|
95
|
-
except AttributeError:
|
|
96
|
-
# _io_loop might not exist in newer Motor versions
|
|
97
|
-
pass
|
|
98
|
-
except RuntimeError:
|
|
99
|
-
# No running loop, which is fine - Motor will handle it
|
|
100
|
-
pass
|
|
101
|
-
|
|
102
|
-
async def verify_connection_async(self) -> bool:
|
|
103
|
-
"""Verify MongoDB connection asynchronously. Safe to call from async context."""
|
|
104
|
-
try:
|
|
105
|
-
# Ensure we're using the current event loop
|
|
106
|
-
self._ensure_async_client_loop()
|
|
107
|
-
await self.async_client.admin.command('ping')
|
|
108
|
-
self._connection_verified = True
|
|
109
|
-
return True
|
|
26
|
+
self.client.admin.command('ping')
|
|
110
27
|
except Exception as e:
|
|
111
|
-
|
|
112
|
-
raise ConnectionError(f"Failed to
|
|
28
|
+
self.client.close()
|
|
29
|
+
raise ConnectionError(f"Failed to connect to MongoDB: {str(e)}")
|
|
113
30
|
|
|
31
|
+
atexit.register(self.close)
|
|
32
|
+
|
|
114
33
|
def close(self) -> None:
|
|
115
34
|
if hasattr(self, 'client'):
|
|
116
35
|
self.client.close()
|
|
117
|
-
if hasattr(self, 'async_client'):
|
|
118
|
-
self.async_client.close()
|
|
@@ -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
|
-
|
|
@@ -5,7 +5,6 @@ import json
|
|
|
5
5
|
from datetime import timedelta
|
|
6
6
|
|
|
7
7
|
from letschatty.models.utils.definitions import Area
|
|
8
|
-
from pydantic_core.core_schema import custom_error_schema
|
|
9
8
|
logger = logging.getLogger("logger")
|
|
10
9
|
|
|
11
10
|
class Context(BaseModel):
|
|
@@ -230,16 +229,4 @@ class OpenAIError(CustomException):
|
|
|
230
229
|
|
|
231
230
|
class NewerAvailableMessageToBeProcessedByAiAgent(CustomException):
|
|
232
231
|
def __init__(self, message="Duplicated incoming message call for ai agent", status_code=400, **context_data):
|
|
233
|
-
super().__init__(message, status_code=status_code, **context_data)
|
|
234
|
-
|
|
235
|
-
class MissingAIAgentInChat(CustomException):
|
|
236
|
-
def __init__(self, message="Missing AI agent in chat", status_code=400, **context_data):
|
|
237
|
-
super().__init__(message, status_code=status_code, **context_data)
|
|
238
|
-
|
|
239
|
-
class ChattyAIModeOff(CustomException):
|
|
240
|
-
def __init__(self, message="Chatty AI agent is in OFF mode", status_code=400, **context_data):
|
|
241
|
-
super().__init__(message, status_code=status_code, **context_data)
|
|
242
|
-
|
|
243
|
-
class ChatWithActiveContinuousConversation(CustomException):
|
|
244
|
-
def __init__(self, message="Chat has active continuous conversation", status_code=400, **context_data):
|
|
245
|
-
super().__init__(message, status_code=status_code, **context_data)
|
|
232
|
+
super().__init__(message, status_code=status_code, **context_data)
|
|
@@ -14,16 +14,13 @@ class SmartFollowUpContextBuilder(ContextBuilder):
|
|
|
14
14
|
|
|
15
15
|
@staticmethod
|
|
16
16
|
def check_minimum_time_since_last_message(chat: Chat, follow_up_strategy: FollowUpStrategy,smart_follow_up_state: FlowStateAssignedToChat) -> bool:
|
|
17
|
-
|
|
18
|
-
# So we add 1 to get the interval for the follow-up we're about to send
|
|
19
|
-
next_followup_number = smart_follow_up_state.consecutive_count + 1
|
|
20
|
-
expected_interval_minutes = follow_up_strategy.get_interval_for_followup(next_followup_number)
|
|
17
|
+
expected_interval_minutes = follow_up_strategy.get_interval_for_followup(smart_follow_up_state.consecutive_count)
|
|
21
18
|
last_message_timestamp = chat.last_message_timestamp
|
|
22
19
|
if last_message_timestamp is None:
|
|
23
20
|
raise HumanInterventionRequired("There's no last message in the chat, can't validate the minimum time since last message for the smart follow up")
|
|
24
21
|
time_since_last_message = datetime.now(ZoneInfo('UTC')) - last_message_timestamp
|
|
25
22
|
if time_since_last_message.total_seconds() < expected_interval_minutes * 60:
|
|
26
|
-
raise PostponeFollowUp(time_delta= timedelta(seconds=expected_interval_minutes * 60 - time_since_last_message.total_seconds()), message=f"Se pospuso el Smart Follow Up porque no ha pasado el tiempo mínimo esperado de {expected_interval_minutes/60} horas para el seguimiento #{
|
|
23
|
+
raise PostponeFollowUp(time_delta= timedelta(seconds=expected_interval_minutes * 60 - time_since_last_message.total_seconds()), message=f"Se pospuso el Smart Follow Up porque no ha pasado el tiempo mínimo esperado de {expected_interval_minutes/60} horas para el seguimiento #{smart_follow_up_state.consecutive_count}")
|
|
27
24
|
return True
|
|
28
25
|
|
|
29
26
|
|
|
@@ -849,6 +849,13 @@ class ChatService:
|
|
|
849
849
|
chat.client.email = collected_data.email
|
|
850
850
|
updated_fields.append("email")
|
|
851
851
|
|
|
852
|
+
if collected_data.phone:
|
|
853
|
+
if chat.client.lead_form_data is None:
|
|
854
|
+
chat.client.lead_form_data = {}
|
|
855
|
+
if chat.client.lead_form_data.get("phone") != collected_data.phone:
|
|
856
|
+
chat.client.lead_form_data["phone"] = collected_data.phone
|
|
857
|
+
updated_fields.append("phone")
|
|
858
|
+
|
|
852
859
|
if collected_data.document_id and chat.client.document_id != collected_data.document_id:
|
|
853
860
|
chat.client.document_id = collected_data.document_id
|
|
854
861
|
updated_fields.append("document_id")
|
|
@@ -873,4 +880,4 @@ class ChatService:
|
|
|
873
880
|
context=ChattyContext(chain_of_thought_id=execution_context.chain_of_thought_id)
|
|
874
881
|
)
|
|
875
882
|
|
|
876
|
-
return chat.client.lead_form_data
|
|
883
|
+
return chat.client.lead_form_data
|
|
@@ -1,14 +1,2 @@
|
|
|
1
1
|
from .base_container import ChattyAssetBaseContainer
|
|
2
2
|
from .base_container_with_collection import ChattyAssetContainerWithCollection
|
|
3
|
-
from .assets_collections import AssetsCollections
|
|
4
|
-
from .services import (
|
|
5
|
-
ProductService,
|
|
6
|
-
TagService,
|
|
7
|
-
UserService,
|
|
8
|
-
ChatService,
|
|
9
|
-
SourceService,
|
|
10
|
-
FlowService,
|
|
11
|
-
SaleService,
|
|
12
|
-
ContactPointService,
|
|
13
|
-
AiAgentService
|
|
14
|
-
)
|