letschatty 0.4.341__py3-none-any.whl → 0.4.342__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 (82) hide show
  1. letschatty/models/ai_microservices/__init__.py +3 -3
  2. letschatty/models/ai_microservices/expected_output.py +29 -2
  3. letschatty/models/ai_microservices/lambda_events.py +137 -28
  4. letschatty/models/ai_microservices/lambda_invokation_types.py +3 -1
  5. letschatty/models/ai_microservices/n8n_ai_agents_payload.py +3 -1
  6. letschatty/models/analytics/events/__init__.py +3 -2
  7. letschatty/models/analytics/events/chat_based_events/ai_agent_execution_event.py +71 -0
  8. letschatty/models/analytics/events/chat_based_events/chat_funnel.py +13 -69
  9. letschatty/models/analytics/events/company_based_events/asset_events.py +2 -9
  10. letschatty/models/analytics/events/event_type_to_classes.py +3 -6
  11. letschatty/models/analytics/events/event_types.py +50 -9
  12. letschatty/models/chat/chat.py +2 -13
  13. letschatty/models/chat/chat_with_assets.py +1 -6
  14. letschatty/models/chat/client.py +2 -0
  15. letschatty/models/chat/continuous_conversation.py +1 -1
  16. letschatty/models/company/CRM/funnel.py +33 -365
  17. letschatty/models/company/__init__.py +1 -2
  18. letschatty/models/company/assets/ai_agents_v2/ai_agents_decision_output.py +1 -1
  19. letschatty/models/company/assets/ai_agents_v2/chatty_ai_agent_in_chat.py +4 -0
  20. letschatty/models/company/assets/ai_agents_v2/chatty_ai_mode.py +2 -2
  21. letschatty/models/company/assets/ai_agents_v2/get_chat_with_prompt_response.py +1 -0
  22. letschatty/models/company/assets/ai_agents_v2/pre_qualify_config.py +14 -2
  23. letschatty/models/company/assets/automation.py +19 -10
  24. letschatty/models/company/assets/chat_assets.py +2 -3
  25. letschatty/models/company/assets/company_assets.py +0 -2
  26. letschatty/models/company/assets/sale.py +3 -3
  27. letschatty/models/data_base/collection_interface.py +101 -29
  28. letschatty/models/data_base/mongo_connection.py +92 -9
  29. letschatty/models/messages/chatty_messages/schema/chatty_content/content_document.py +2 -4
  30. letschatty/models/messages/chatty_messages/schema/chatty_content/content_media.py +3 -4
  31. letschatty/models/utils/custom_exceptions/custom_exceptions.py +14 -1
  32. letschatty/services/ai_agents/smart_follow_up_context_builder_v2.py +5 -2
  33. letschatty/services/chat/chat_service.py +11 -47
  34. letschatty/services/chatty_assets/__init__.py +12 -0
  35. letschatty/services/chatty_assets/asset_service.py +190 -13
  36. letschatty/services/chatty_assets/assets_collections.py +137 -0
  37. letschatty/services/chatty_assets/base_container.py +3 -2
  38. letschatty/services/chatty_assets/base_container_with_collection.py +35 -26
  39. letschatty/services/chatty_assets/collections/__init__.py +38 -0
  40. letschatty/services/chatty_assets/collections/ai_agent_collection.py +19 -0
  41. letschatty/services/chatty_assets/collections/ai_agent_in_chat_collection.py +32 -0
  42. letschatty/services/chatty_assets/collections/ai_component_collection.py +21 -0
  43. letschatty/services/chatty_assets/collections/chain_of_thought_collection.py +30 -0
  44. letschatty/services/chatty_assets/collections/chat_collection.py +21 -0
  45. letschatty/services/chatty_assets/collections/contact_point_collection.py +21 -0
  46. letschatty/services/chatty_assets/collections/fast_answer_collection.py +21 -0
  47. letschatty/services/chatty_assets/collections/filter_criteria_collection.py +18 -0
  48. letschatty/services/chatty_assets/collections/flow_collection.py +20 -0
  49. letschatty/services/chatty_assets/collections/product_collection.py +20 -0
  50. letschatty/services/chatty_assets/collections/sale_collection.py +20 -0
  51. letschatty/services/chatty_assets/collections/source_collection.py +21 -0
  52. letschatty/services/chatty_assets/collections/tag_collection.py +19 -0
  53. letschatty/services/chatty_assets/collections/topic_collection.py +21 -0
  54. letschatty/services/chatty_assets/collections/user_collection.py +20 -0
  55. letschatty/services/chatty_assets/example_usage.py +44 -0
  56. letschatty/services/chatty_assets/services/__init__.py +37 -0
  57. letschatty/services/chatty_assets/services/ai_agent_in_chat_service.py +73 -0
  58. letschatty/services/chatty_assets/services/ai_agent_service.py +23 -0
  59. letschatty/services/chatty_assets/services/chain_of_thought_service.py +70 -0
  60. letschatty/services/chatty_assets/services/chat_service.py +25 -0
  61. letschatty/services/chatty_assets/services/contact_point_service.py +29 -0
  62. letschatty/services/chatty_assets/services/fast_answer_service.py +32 -0
  63. letschatty/services/chatty_assets/services/filter_criteria_service.py +30 -0
  64. letschatty/services/chatty_assets/services/flow_service.py +25 -0
  65. letschatty/services/chatty_assets/services/product_service.py +30 -0
  66. letschatty/services/chatty_assets/services/sale_service.py +25 -0
  67. letschatty/services/chatty_assets/services/source_service.py +28 -0
  68. letschatty/services/chatty_assets/services/tag_service.py +32 -0
  69. letschatty/services/chatty_assets/services/topic_service.py +31 -0
  70. letschatty/services/chatty_assets/services/user_service.py +32 -0
  71. letschatty/services/continuous_conversation_service/continuous_conversation_helper.py +11 -0
  72. letschatty/services/events/__init__.py +6 -0
  73. letschatty/services/events/events_manager.py +218 -1
  74. letschatty/services/factories/analytics/ai_agent_event_factory.py +161 -0
  75. letschatty/services/factories/analytics/events_factory.py +66 -6
  76. letschatty/services/factories/lambda_ai_orchestrartor/lambda_events_factory.py +25 -8
  77. letschatty/services/messages_helpers/get_caption_or_body_or_preview.py +6 -4
  78. letschatty/services/validators/analytics_validator.py +0 -11
  79. {letschatty-0.4.341.dist-info → letschatty-0.4.342.dist-info}/METADATA +1 -1
  80. {letschatty-0.4.341.dist-info → letschatty-0.4.342.dist-info}/RECORD +82 -46
  81. {letschatty-0.4.341.dist-info → letschatty-0.4.342.dist-info}/LICENSE +0 -0
  82. {letschatty-0.4.341.dist-info → letschatty-0.4.342.dist-info}/WHEEL +0 -0
@@ -10,7 +10,7 @@ from datetime import datetime
10
10
  from zoneinfo import ZoneInfo
11
11
  from bson import ObjectId
12
12
  import json
13
- from typing import Dict, Any, Optional, List
13
+ from typing import Dict, Any, Optional
14
14
  from letschatty.models.utils.types.serializer_type import SerializerType
15
15
  from letschatty.models.company.assets.ai_agents_v2.chatty_ai_mode import ChattyAIMode
16
16
 
@@ -67,8 +67,7 @@ class AssignedAssetToChat(BaseModel):
67
67
 
68
68
 
69
69
  class SaleAssignedToChat(AssignedAssetToChat):
70
- product_id: Optional[StrObjectId] = Field(default=None)
71
- product_ids: List[StrObjectId] = Field(default_factory=list)
70
+ product_id: StrObjectId = Field(frozen=True)
72
71
 
73
72
  class ContactPointAssignedToChat(AssignedAssetToChat):
74
73
  source_id: StrObjectId = Field(frozen=True)
@@ -5,8 +5,6 @@ class CompanyAssetType(StrEnum):
5
5
  USERS = "users"
6
6
  BUSINESS_AREAS = "business_areas"
7
7
  FUNNELS = "funnels"
8
- FUNNEL_STAGES = "funnel_stages"
9
- FUNNEL_MEMBERS = "funnel_members"
10
8
  PRODUCTS = "products"
11
9
  SALES = "sales"
12
10
  TAGS = "tags"
@@ -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: Optional[StrObjectId] = Field(default=None)
8
+ product_id: StrObjectId
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: Optional[StrObjectId] = Field(default=None)
19
+ product_id: StrObjectId
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
+ }
@@ -4,6 +4,7 @@ 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
7
8
 
8
9
  from letschatty.models.chat.chat import Chat
9
10
  from ...models.base_models.chatty_asset_model import ChattyAssetModel, CompanyAssetModel, ChattyAssetPreview
@@ -26,58 +27,116 @@ P = TypeVar('P', bound=ChattyAssetPreview)
26
27
  class ChattyAssetCollectionInterface(Generic[T, P], ABC):
27
28
  def __init__(self, database: str, collection: str, connection: MongoConnection, type: Type[T], preview_type: Optional[Type[P]] = None):
28
29
  logger.info(f"Initializing collection {collection} in database {database}")
30
+ # Sync database and collection (existing)
29
31
  self.db: Database = connection.client[database]
30
32
  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
+
31
42
  self.type = type
32
43
  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
33
62
  @abstractmethod
34
63
  def create_instance(self, data: dict) -> T:
35
64
  """Factory method to create instance from data"""
36
65
  pass
37
66
 
38
- def insert(self, asset: T) -> StrObjectId:
67
+ # All methods are now async-only for better performance
68
+ async def insert(self, asset: T) -> StrObjectId:
69
+ """Async insert operation"""
39
70
  if not isinstance(asset, self.type):
40
71
  raise ValueError(f"Asset must be of type {self.type.__name__}")
41
72
  document = asset.model_dump_json(serializer=SerializerType.DATABASE)
42
73
  logger.debug(f"Inserting document: {document}")
43
- result = self.collection.insert_one(document)
74
+ result = await self.async_collection.insert_one(document)
44
75
  if not result.inserted_id:
45
76
  raise Exception("Failed to insert document")
46
77
  logger.debug(f"Inserted document with id {result.inserted_id}")
47
78
  return result.inserted_id
48
79
 
49
- def update(self, asset: T) -> StrObjectId:
80
+ async def update(self, asset: T) -> StrObjectId:
81
+ """Async update operation"""
50
82
  logger.debug(f"Updating document with id {asset.id}")
51
83
  if not isinstance(asset, self.type):
52
84
  raise ValueError(f"Asset must be of type {self.type.__name__}")
53
85
  asset.update_now()
54
86
  document = asset.model_dump_json(serializer=SerializerType.DATABASE)
55
- document.pop('_id', None) # Still needed
56
- result = self.collection.update_one({"_id": ObjectId(asset.id)}, {"$set": document})
87
+ document.pop('_id', None)
88
+ result = await self.async_collection.update_one(
89
+ {"_id": ObjectId(asset.id)},
90
+ {"$set": document}
91
+ )
57
92
  if result.matched_count == 0:
58
93
  raise NotFoundError(f"No document found with id {asset.id}")
59
94
  if result.modified_count == 0:
60
95
  logger.debug(f"No changes were made to the document with id {asset.id} probably because the values were the same")
61
96
  return asset.id
62
97
 
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
-
98
+ async def get_by_id(self, doc_id: str) -> T:
99
+ """Get by ID operation"""
100
+ logger.debug(f"Getting document with id {doc_id} from collection {self.async_collection.name}")
101
+ doc = await self.async_collection.find_one({"_id": ObjectId(doc_id)})
67
102
  if doc:
68
103
  return self.create_instance(doc)
69
104
  else:
70
- raise NotFoundError(f"No document found with id {doc_id} in db collection {self.collection.name} and db {self.db.name}")
105
+ raise NotFoundError(f"No document found with id {doc_id} in collection")
71
106
 
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}")
107
+ async def get_docs(self, company_id: Optional[StrObjectId], query={}, limit=0) -> List[T]:
108
+ """Get multiple documents operation"""
109
+ logger.debug(f"Getting documents from collection with company_id {company_id} and query {query}")
74
110
  if company_id:
75
- query = query.copy() # Create a copy to avoid modifying the original
111
+ query = query.copy()
76
112
  query["company_id"] = company_id
77
- docs = list(self.collection.find(filter=query).limit(limit))
78
- logger.debug(f"Found {len(docs)} documents in collection {self.collection.name}")
113
+ cursor = self.async_collection.find(filter=query)
114
+ if limit:
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")
79
118
  return [self.create_instance(doc) for doc in docs]
80
119
 
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
81
140
  def get_preview_docs(self, projection = {}, all=True) -> List[P]:
82
141
  """We get the previews of all the documents in the collection for all companies"""
83
142
  if not self.preview_type:
@@ -98,18 +157,31 @@ class ChattyAssetCollectionInterface(Generic[T, P], ABC):
98
157
  docs = self.collection.find(query)
99
158
  return [self.create_instance(doc) for doc in docs]
100
159
 
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}")
160
+ async def get_by_ids(self, ids: List[StrObjectId]) -> List[T]:
161
+ """
162
+ Get multiple assets by their IDs in a single query.
163
+
164
+ Args:
165
+ ids: List of asset IDs
166
+
167
+ Returns:
168
+ List of assets objects
169
+ """
170
+ if not ids:
171
+ return []
172
+
173
+ # Convert string IDs to ObjectIds
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]
115
187
 
@@ -1,8 +1,14 @@
1
1
  from ..base_models.singleton import SingletonMeta
2
2
  from pymongo import MongoClient
3
+ from motor.motor_asyncio import AsyncIOMotorClient
3
4
  from typing import Optional
5
+ from urllib.parse import quote_plus
4
6
  import os
5
7
  import atexit
8
+ import asyncio
9
+ import logging
10
+
11
+ logger = logging.getLogger(__name__)
6
12
 
7
13
  class MongoConnection(metaclass=SingletonMeta):
8
14
  def __init__(
@@ -10,26 +16,103 @@ class MongoConnection(metaclass=SingletonMeta):
10
16
  username: Optional[str] = None,
11
17
  password: Optional[str] = None,
12
18
  uri_base: Optional[str] = None,
13
- instance: Optional[str] = None
19
+ instance: Optional[str] = None,
20
+ verify_on_init: bool = True
14
21
  ):
15
22
  self.username = username or os.getenv('MONGO_USERNAME')
16
23
  self.password = password or os.getenv('MONGO_PASSWORD')
17
24
  self.uri_base = uri_base or os.getenv('MONGO_URI_BASE')
18
25
  self.instance = instance or os.getenv('MONGO_INSTANCE_COMPONENT')
19
-
26
+
20
27
  if not all([self.username, self.password, self.uri_base, self.instance]):
21
28
  raise ValueError("Missing required MongoDB connection parameters")
22
-
23
- uri = f"{self.uri_base}://{self.username}:{self.password}@{self.instance}.mongodb.net"
29
+
30
+ # URL-encode username and password to handle special characters per RFC 3986
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)
24
37
  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)"""
25
78
  try:
26
- self.client.admin.command('ping')
79
+ current_loop = asyncio.get_running_loop()
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
27
110
  except Exception as e:
28
- self.client.close()
29
- raise ConnectionError(f"Failed to connect to MongoDB: {str(e)}")
111
+ logger.error(f"Failed to verify MongoDB connection: {e}")
112
+ raise ConnectionError(f"Failed to verify MongoDB connection: {e}")
30
113
 
31
- atexit.register(self.close)
32
-
33
114
  def close(self) -> None:
34
115
  if hasattr(self, 'client'):
35
116
  self.client.close()
117
+ if hasattr(self, 'async_client'):
118
+ self.async_client.close()
@@ -1,6 +1,5 @@
1
1
  from pydantic import BaseModel, Field, model_validator, ValidationInfo
2
2
  from typing import Optional
3
- from urllib.parse import urlparse, unquote
4
3
  from .content_media import ChattyContentMedia
5
4
 
6
5
  class ChattyContentDocument(ChattyContentMedia):
@@ -9,6 +8,5 @@ class ChattyContentDocument(ChattyContentMedia):
9
8
  @model_validator(mode='before')
10
9
  def validate_filename(cls, data: dict, info: ValidationInfo):
11
10
  if not data.get("filename") and data.get("url"):
12
- parsed = urlparse(data["url"])
13
- data["filename"] = unquote(parsed.path.split("/")[-1])
14
- return data
11
+ data["filename"] = data["url"].split("/")[-1]
12
+ return data
@@ -1,6 +1,5 @@
1
1
  from pydantic import BaseModel, Field, field_validator, HttpUrl
2
2
  from typing import Optional
3
- from urllib.parse import quote
4
3
  class ChattyContentMedia(BaseModel):
5
4
  id: Optional[str] = Field(description="Unique identifier for the image. Also known as media_id", default="")
6
5
  url: str = Field(description="URL of the media from S3")
@@ -12,9 +11,9 @@ class ChattyContentMedia(BaseModel):
12
11
  def validate_url(cls, v):
13
12
  if not v:
14
13
  raise ValueError("URL is required")
15
- encoded = quote(str(v), safe=":/?&=%#")
16
- HttpUrl(encoded)
17
- return encoded
14
+ HttpUrl(v)
15
+ return v
18
16
 
19
17
  def get_body_or_caption(self) -> str:
20
18
  return self.caption
19
+
@@ -5,6 +5,7 @@ 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
8
9
  logger = logging.getLogger("logger")
9
10
 
10
11
  class Context(BaseModel):
@@ -229,4 +230,16 @@ class OpenAIError(CustomException):
229
230
 
230
231
  class NewerAvailableMessageToBeProcessedByAiAgent(CustomException):
231
232
  def __init__(self, message="Duplicated incoming message call for ai agent", status_code=400, **context_data):
232
- super().__init__(message, status_code=status_code, **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)
@@ -14,13 +14,16 @@ 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
- expected_interval_minutes = follow_up_strategy.get_interval_for_followup(smart_follow_up_state.consecutive_count)
17
+ # consecutive_count is 0-indexed (0 = no follow-ups sent yet), but get_interval_for_followup expects 1-indexed
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)
18
21
  last_message_timestamp = chat.last_message_timestamp
19
22
  if last_message_timestamp is None:
20
23
  raise HumanInterventionRequired("There's no last message in the chat, can't validate the minimum time since last message for the smart follow up")
21
24
  time_since_last_message = datetime.now(ZoneInfo('UTC')) - last_message_timestamp
22
25
  if time_since_last_message.total_seconds() < expected_interval_minutes * 60:
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}")
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 #{next_followup_number}")
24
27
  return True
25
28
 
26
29
 
@@ -211,25 +211,6 @@ class ChatService:
211
211
  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
212
  return chat.chatty_ai_agent
213
213
 
214
- @staticmethod
215
- def escalate_chatty_ai_agent(chat: Chat, execution_context: ExecutionContext, message: Optional[str] = None) -> None:
216
- """
217
- Mark the chat's AI agent as requiring human intervention and add a central notification.
218
- """
219
- if chat.chatty_ai_agent and not chat.chatty_ai_agent.requires_human_intervention:
220
- chat.chatty_ai_agent.requires_human_intervention = True
221
- execution_context.set_event_time(datetime.now(tz=ZoneInfo("UTC")))
222
- body = "El chat fue escalado a un agente humano"
223
- if message:
224
- body = f"{body}: {message}"
225
- ChatService.add_central_notification_from_text(
226
- chat=chat,
227
- body=body,
228
- subtype=MessageSubtype.CHATTY_AI_AGENT_NOTIFICATION,
229
- content_status=CentralNotificationStatus.WARNING,
230
- context=ChattyContext(chain_of_thought_id=execution_context.chain_of_thought_id)
231
- )
232
-
233
214
  @staticmethod
234
215
  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:
235
216
  """
@@ -285,46 +266,36 @@ class ChatService:
285
266
  return next((state for state in chat.flow_states if state.is_smart_follow_up), None)
286
267
 
287
268
  @staticmethod
288
- def create_sale(
289
- chat: Chat,
290
- execution_context: ExecutionContext,
291
- sale: Sale,
292
- product: Optional[Product],
293
- product_ids: Optional[List[StrObjectId]] = None,
294
- product_label: Optional[str] = None
295
- ) -> SaleAssignedToChat:
269
+ def create_sale(chat : Chat, execution_context: ExecutionContext, sale : Sale, product : Product) -> SaleAssignedToChat:
296
270
  """
297
271
  Add a sale to the chat.
298
272
  """
299
273
  if next((sale for sale in chat.client.sales if sale.asset_id == sale.id), None) is not None:
300
274
  raise AssetAlreadyAssigned(f"Sale with id {sale.id} already assigned to chat {chat.id}")
301
- label = product_label or (product.name if product else "multiples productos")
302
- assigned_product_ids = product_ids or ([product.id] if product else [])
303
275
  assigned_asset = SaleAssignedToChat(
304
276
  asset_type=ChatAssetType.SALE,
305
277
  asset_id=sale.id,
306
278
  assigned_at=sale.created_at,
307
279
  assigned_by=execution_context.executor.id,
308
- product_id=product.id if product else None,
309
- product_ids=assigned_product_ids
280
+ product_id=product.id
310
281
  )
311
282
  execution_context.set_event_time(assigned_asset.assigned_at)
312
283
  bisect.insort(chat.client.sales, assigned_asset)
313
- 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))
314
- 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)
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)
315
286
  return assigned_asset
316
287
 
317
288
  @staticmethod
318
- def update_sale(chat: Chat, execution_context: ExecutionContext, sale: Sale, product_label: str) -> Sale:
289
+ def update_sale(chat : Chat, execution_context: ExecutionContext, sale : Sale, product : Product) -> Sale:
319
290
  """
320
291
  Update a sale for the chat.
321
292
  """
322
- 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))
323
- 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)
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)
324
295
  return sale
325
296
 
326
297
  @staticmethod
327
- def delete_sale(chat: Chat, execution_context: ExecutionContext, sale_id: StrObjectId, product_label: str) -> SaleAssignedToChat:
298
+ def delete_sale(chat : Chat, execution_context: ExecutionContext, sale_id : StrObjectId, product : Product) -> SaleAssignedToChat:
328
299
  """
329
300
  Logically remove a sale from the chat.
330
301
  """
@@ -332,8 +303,8 @@ class ChatService:
332
303
  assigned_asset_to_remove = next(sale for sale in chat.client.sales if sale.asset_id == sale_id)
333
304
  chat.client.sales.remove(assigned_asset_to_remove)
334
305
  execution_context.set_event_time(datetime.now(tz=ZoneInfo("UTC")))
335
- 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))
336
- 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)
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)
337
308
  return assigned_asset_to_remove
338
309
  except StopIteration:
339
310
  raise NotFoundError(message=f"Sale with id {sale_id} not found in chat {chat.id}")
@@ -902,16 +873,9 @@ class ChatService:
902
873
  execution_context.set_event_time(datetime.now(tz=ZoneInfo("UTC")))
903
874
  logger.info(f"Updated collected data for chat {chat.id}: {', '.join(updated_fields)}")
904
875
 
905
- field_label_map = {
906
- "name": "nombre",
907
- "email": "email",
908
- "phone": "telefono",
909
- "document_id": "dni",
910
- }
911
- display_fields = [field_label_map.get(field, field) for field in updated_fields]
912
876
  ChatService.add_central_notification_from_text(
913
877
  chat=chat,
914
- body=f"Datos del cliente recopilados: {', '.join(display_fields)}",
878
+ body=f"Collected customer data: {', '.join(updated_fields)}",
915
879
  subtype=MessageSubtype.CLIENT_INFO_UPDATED,
916
880
  context=ChattyContext(chain_of_thought_id=execution_context.chain_of_thought_id)
917
881
  )
@@ -1,2 +1,14 @@
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
+ )