letschatty 0.4.350__py3-none-any.whl → 0.4.352__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 +4 -4
- letschatty/models/ai_microservices/expected_output.py +2 -29
- letschatty/models/ai_microservices/lambda_events.py +28 -155
- letschatty/models/ai_microservices/lambda_invokation_types.py +1 -4
- letschatty/models/ai_microservices/n8n_ai_agents_payload.py +1 -3
- letschatty/models/analytics/events/__init__.py +3 -3
- letschatty/models/analytics/events/chat_based_events/chat_client.py +19 -0
- 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 +7 -3
- letschatty/models/analytics/events/event_types.py +11 -50
- 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 +10 -1
- letschatty/models/company/assets/ai_agents_v2/ai_agents_decision_output.py +1 -1
- letschatty/models/company/assets/ai_agents_v2/chatty_ai_agent_in_chat.py +0 -4
- 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 +1 -29
- letschatty/models/company/assets/automation.py +10 -19
- letschatty/models/company/assets/chat_assets.py +3 -2
- letschatty/models/company/assets/company_assets.py +2 -0
- letschatty/models/company/assets/sale.py +3 -3
- letschatty/models/company/empresa.py +4 -1
- letschatty/models/company/integrations/product_sync_status.py +28 -0
- letschatty/models/company/integrations/shopify/company_shopify_integration.py +62 -0
- letschatty/models/company/integrations/shopify/shopify_product_sync_status.py +18 -0
- letschatty/models/company/integrations/shopify/shopify_webhook_topics.py +40 -0
- letschatty/models/company/integrations/sync_status_enum.py +9 -0
- letschatty/models/company/integrations/tienda_nube/company_tienda_nube_integration.py +62 -0
- letschatty/models/company/integrations/tienda_nube/tienda_nube_product_sync_status.py +18 -0
- letschatty/models/company/integrations/tienda_nube/tienda_nube_webhook_topics.py +46 -0
- 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 +47 -11
- 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 +30 -66
- letschatty/services/factories/lambda_ai_orchestrartor/lambda_events_factory.py +8 -46
- letschatty/services/messages_helpers/get_caption_or_body_or_preview.py +4 -6
- letschatty/services/validators/analytics_validator.py +11 -0
- {letschatty-0.4.350.dist-info → letschatty-0.4.352.dist-info}/METADATA +1 -1
- {letschatty-0.4.350.dist-info → letschatty-0.4.352.dist-info}/RECORD +56 -83
- 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.350.dist-info → letschatty-0.4.352.dist-info}/LICENSE +0 -0
- {letschatty-0.4.350.dist-info → letschatty-0.4.352.dist-info}/WHEEL +0 -0
letschatty/models/chat/chat.py
CHANGED
|
@@ -19,6 +19,7 @@ from ..utils.custom_exceptions.custom_exceptions import MissingAIAgentForSmartFo
|
|
|
19
19
|
from .time_left import TimeLeft
|
|
20
20
|
from ..company.conversation_topic import TopicTimelineEntry
|
|
21
21
|
from ..company.form_field import CollectedData
|
|
22
|
+
from ..company.CRM.funnel import ActiveFunnel
|
|
22
23
|
import json
|
|
23
24
|
import logging
|
|
24
25
|
logger = logging.getLogger(__name__)
|
|
@@ -47,6 +48,7 @@ class Chat(CompanyAssetModel):
|
|
|
47
48
|
chatty_ai_agent: Optional[ChattyAIAgentAssignedToChat] = Field(default=None, description="The id of the chatty ai agent that might or might not be assigned to the chat")
|
|
48
49
|
suggested_messages: List[MessageDraft] = Field(default_factory=list)
|
|
49
50
|
topics_timeline: List[TopicTimelineEntry] = Field(default_factory=list, description="Timeline of conversation topics throughout the conversation")
|
|
51
|
+
active_funnel: Optional[ActiveFunnel] = Field(default=None, description="Current active funnel for this chat")
|
|
50
52
|
model_config = ConfigDict(extra="ignore")
|
|
51
53
|
|
|
52
54
|
@property
|
|
@@ -267,7 +269,17 @@ class Chat(CompanyAssetModel):
|
|
|
267
269
|
@property
|
|
268
270
|
def bought_product_ids(self) -> List[StrObjectId]:
|
|
269
271
|
"""Get all sale ids in the chat"""
|
|
270
|
-
|
|
272
|
+
product_ids: List[StrObjectId] = []
|
|
273
|
+
for sale in self.sales:
|
|
274
|
+
sale_product_ids = getattr(sale, "product_ids", None)
|
|
275
|
+
if sale_product_ids:
|
|
276
|
+
for product_id in sale_product_ids:
|
|
277
|
+
if product_id not in product_ids:
|
|
278
|
+
product_ids.append(product_id)
|
|
279
|
+
elif sale.product_id:
|
|
280
|
+
if sale.product_id not in product_ids:
|
|
281
|
+
product_ids.append(sale.product_id)
|
|
282
|
+
return product_ids
|
|
271
283
|
|
|
272
284
|
@property
|
|
273
285
|
def products(self) -> List[AssignedAssetToChat]:
|
|
@@ -364,4 +376,3 @@ class Chat(CompanyAssetModel):
|
|
|
364
376
|
dump["last_message"] = self.last_message.model_dump_json(serializer=SerializerType.DATABASE) if self.last_message else None
|
|
365
377
|
dump["flow_states"] = [flow_state.model_dump_json(serializer=SerializerType.DATABASE) for flow_state in self.flow_states]
|
|
366
378
|
return dump
|
|
367
|
-
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
|
|
2
1
|
from .chat import Chat
|
|
3
2
|
from letschatty.models.company.assets import TagPreview, FlowPreview, ContactPoint, Sale, ChattyAIAgentPreview, ProductPreview
|
|
3
|
+
from letschatty.models.company.CRM.funnel import ActiveFunnel
|
|
4
4
|
|
|
5
5
|
from letschatty.models.utils.types.serializer_type import SerializerType
|
|
6
6
|
from pydantic import BaseModel
|
|
@@ -15,3 +15,8 @@ class ChatWithAssets(BaseModel):
|
|
|
15
15
|
contact_points: List[ContactPoint]
|
|
16
16
|
flows_links_states: List[FlowPreview]
|
|
17
17
|
chatty_ai_agent: Optional[ChattyAIAgentPreview]
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def active_funnel(self) -> Optional[ActiveFunnel]:
|
|
21
|
+
"""Convenience property to access the chat's active funnel"""
|
|
22
|
+
return self.chat.active_funnel
|
letschatty/models/chat/client.py
CHANGED
|
@@ -5,7 +5,6 @@ from ..utils.types.identifier import StrObjectId
|
|
|
5
5
|
from .quality_scoring import QualityScore
|
|
6
6
|
from .highlight import Highlight
|
|
7
7
|
from ..company.assets.chat_assets import AssignedAssetToChat, SaleAssignedToChat, ContactPointAssignedToChat
|
|
8
|
-
from ..company.CRM.funnel import ClientFunnel
|
|
9
8
|
from ..utils.types.serializer_type import SerializerType
|
|
10
9
|
|
|
11
10
|
class Client(CompanyAssetModel):
|
|
@@ -23,7 +22,6 @@ class Client(CompanyAssetModel):
|
|
|
23
22
|
contact_points: List[ContactPointAssignedToChat] = Field(default=list())
|
|
24
23
|
business_area: Optional[StrObjectId] = Field(default=None, description="It's a business related area, that works as a queue for the chats")
|
|
25
24
|
external_id: Optional[str] = Field(default=None)
|
|
26
|
-
funnels : List[ClientFunnel] = Field(default=list())
|
|
27
25
|
exclude_fields = {
|
|
28
26
|
SerializerType.FRONTEND: {"products", "tags", "sales", "contact_points", "highlights"}
|
|
29
27
|
}
|
|
@@ -30,7 +30,7 @@ class ContinuousConversation(ChattyAssetModel):
|
|
|
30
30
|
template_message_waid: Optional[str] = None
|
|
31
31
|
status: Optional[ContinuousConversationStatus] = Field(default=ContinuousConversationStatus.CREATED)
|
|
32
32
|
active: bool = Field(default=True)
|
|
33
|
-
expires_at: datetime = Field(
|
|
33
|
+
expires_at: datetime = Field(default_factory=lambda: datetime.now(ZoneInfo("UTC")) + timedelta(days=10))
|
|
34
34
|
messages: List[MessageDraft]
|
|
35
35
|
creator_id: StrObjectId
|
|
36
36
|
forced_send: bool = Field(default=False)
|
|
@@ -1,69 +1,401 @@
|
|
|
1
|
-
from ...base_models import CompanyAssetModel
|
|
2
|
-
from typing import List
|
|
1
|
+
from ...base_models import CompanyAssetModel, ChattyAssetPreview, ChattyAssetModel
|
|
2
|
+
from typing import List, Optional, ClassVar, Any
|
|
3
3
|
from ...utils.types.identifier import StrObjectId
|
|
4
|
-
from pydantic import BaseModel, Field
|
|
4
|
+
from pydantic import BaseModel, Field, ConfigDict
|
|
5
5
|
from enum import StrEnum
|
|
6
6
|
from datetime import datetime
|
|
7
|
-
from typing import Optional
|
|
8
7
|
from zoneinfo import ZoneInfo
|
|
9
8
|
from ...utils.types.executor_types import ExecutorType
|
|
9
|
+
from ..assets.automation import Automation
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ============================================================================
|
|
13
|
+
# Enums
|
|
14
|
+
# ============================================================================
|
|
10
15
|
|
|
11
16
|
class FunnelStatus(StrEnum):
|
|
17
|
+
"""Status of a chat within a funnel"""
|
|
12
18
|
IN_PROGRESS = "in_progress"
|
|
13
19
|
COMPLETED = "completed"
|
|
14
20
|
ABANDONED = "abandoned"
|
|
15
21
|
|
|
22
|
+
|
|
23
|
+
class FunnelMemberRole(StrEnum):
|
|
24
|
+
"""Role of a user within a funnel"""
|
|
25
|
+
ADMIN = "admin" # Full control over funnel settings, stages, and members
|
|
26
|
+
EDITOR = "editor" # Can move chats between stages, view all chats
|
|
27
|
+
VIEWER = "viewer" # Read-only access to funnel and chats
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ============================================================================
|
|
31
|
+
# Embedded Models (BaseModel)
|
|
32
|
+
# ============================================================================
|
|
33
|
+
|
|
16
34
|
class StageTransition(BaseModel):
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
35
|
+
"""
|
|
36
|
+
Record of a chat transitioning between stages within a funnel.
|
|
37
|
+
Tracks the transition details and time spent in the previous stage.
|
|
38
|
+
"""
|
|
39
|
+
from_stage_id: Optional[StrObjectId] = Field(
|
|
40
|
+
default=None,
|
|
41
|
+
description="The stage the chat came from (None if entering funnel)"
|
|
42
|
+
)
|
|
43
|
+
to_stage_id: StrObjectId = Field(description="The stage the chat moved to")
|
|
44
|
+
transitioned_at: datetime = Field(default_factory=lambda: datetime.now(ZoneInfo("UTC")))
|
|
45
|
+
executor_type: ExecutorType = Field(description="Type of executor that triggered the transition")
|
|
46
|
+
executor_id: StrObjectId = Field(description="ID of the executor that triggered the transition")
|
|
47
|
+
time_in_previous_stage_seconds: Optional[int] = Field(
|
|
48
|
+
default=None,
|
|
49
|
+
description="Time spent in the previous stage before this transition"
|
|
50
|
+
)
|
|
51
|
+
from_stage_order: Optional[int] = Field(
|
|
52
|
+
default=None,
|
|
53
|
+
description="Order of the from_stage at transition time (for detecting regressions)"
|
|
54
|
+
)
|
|
55
|
+
to_stage_order: Optional[int] = Field(
|
|
56
|
+
default=None,
|
|
57
|
+
description="Order of the to_stage at transition time"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def is_regression(self) -> bool:
|
|
62
|
+
"""Check if this transition moved backwards in the funnel"""
|
|
63
|
+
if self.from_stage_order is None or self.to_stage_order is None:
|
|
64
|
+
return False
|
|
65
|
+
return self.to_stage_order < self.from_stage_order
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def is_entry(self) -> bool:
|
|
69
|
+
"""Check if this is an entry transition (no previous stage)"""
|
|
70
|
+
return self.from_stage_id is None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ActiveFunnel(BaseModel):
|
|
74
|
+
"""
|
|
75
|
+
Lightweight funnel state embedded in Chat for fast filtering and display.
|
|
76
|
+
|
|
77
|
+
The full history (stage_transitions, time metrics) is stored in the
|
|
78
|
+
separate chat_funnels collection via ChatFunnel.
|
|
79
|
+
"""
|
|
80
|
+
chat_funnel_id: StrObjectId = Field(
|
|
81
|
+
description="Reference to the full ChatFunnel document in chat_funnels collection"
|
|
82
|
+
)
|
|
83
|
+
funnel_id: StrObjectId = Field(description="The funnel the chat is in")
|
|
84
|
+
funnel_name: str = Field(description="Denormalized funnel name for display")
|
|
85
|
+
current_stage_id: StrObjectId = Field(description="Current stage ID")
|
|
86
|
+
current_stage_name: str = Field(description="Denormalized stage name for display")
|
|
87
|
+
entered_current_stage_at: datetime = Field(
|
|
88
|
+
default_factory=lambda: datetime.now(ZoneInfo("UTC")),
|
|
89
|
+
description="When the chat entered the current stage"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def time_in_current_stage_seconds(self) -> int:
|
|
94
|
+
"""Calculate time spent in the current stage"""
|
|
95
|
+
return int((datetime.now(tz=ZoneInfo("UTC")) - self.entered_current_stage_at).total_seconds())
|
|
96
|
+
|
|
97
|
+
def update_stage(self, stage_id: StrObjectId, stage_name: str) -> None:
|
|
98
|
+
"""Update the current stage (called when transitioning)"""
|
|
99
|
+
self.current_stage_id = stage_id
|
|
100
|
+
self.current_stage_name = stage_name
|
|
101
|
+
self.entered_current_stage_at = datetime.now(tz=ZoneInfo("UTC"))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ============================================================================
|
|
105
|
+
# Preview Classes - For efficient listing
|
|
106
|
+
# ============================================================================
|
|
107
|
+
|
|
108
|
+
class FunnelPreview(ChattyAssetPreview):
|
|
109
|
+
"""Preview of a Funnel for listing without full data"""
|
|
110
|
+
is_active: bool = Field(default=True)
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def get_projection(cls) -> dict[str, Any]:
|
|
114
|
+
base = super().get_projection()
|
|
115
|
+
base["is_active"] = 1
|
|
116
|
+
return base
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def from_asset(cls, asset: 'Funnel') -> 'FunnelPreview':
|
|
120
|
+
return cls(
|
|
121
|
+
_id=asset.id,
|
|
122
|
+
name=asset.name,
|
|
123
|
+
company_id=asset.company_id,
|
|
124
|
+
created_at=asset.created_at,
|
|
125
|
+
deleted_at=asset.deleted_at,
|
|
126
|
+
is_active=asset.is_active
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class FunnelStagePreview(ChattyAssetPreview):
|
|
131
|
+
"""Preview of a FunnelStage for listing"""
|
|
25
132
|
funnel_id: StrObjectId
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
133
|
+
color: str
|
|
134
|
+
order: int
|
|
135
|
+
is_exit_stage: bool = Field(default=False)
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def get_projection(cls) -> dict[str, Any]:
|
|
139
|
+
base = super().get_projection()
|
|
140
|
+
base.update({
|
|
141
|
+
"funnel_id": 1,
|
|
142
|
+
"color": 1,
|
|
143
|
+
"order": 1,
|
|
144
|
+
"is_exit_stage": 1
|
|
145
|
+
})
|
|
146
|
+
return base
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
def from_asset(cls, asset: 'FunnelStage') -> 'FunnelStagePreview':
|
|
150
|
+
return cls(
|
|
151
|
+
_id=asset.id,
|
|
152
|
+
name=asset.name,
|
|
153
|
+
company_id=asset.company_id,
|
|
154
|
+
created_at=asset.created_at,
|
|
155
|
+
deleted_at=asset.deleted_at,
|
|
156
|
+
funnel_id=asset.funnel_id,
|
|
157
|
+
color=asset.color,
|
|
158
|
+
order=asset.order,
|
|
159
|
+
is_exit_stage=asset.is_exit_stage
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ============================================================================
|
|
164
|
+
# Company Assets (CompanyAssetModel) - Stored in separate collections
|
|
165
|
+
# ============================================================================
|
|
166
|
+
|
|
167
|
+
class Funnel(CompanyAssetModel):
|
|
168
|
+
"""
|
|
169
|
+
A funnel represents a pipeline/process for managing chats (e.g., sales funnel, support pipeline).
|
|
170
|
+
Companies can create multiple funnels to organize their chat workflows.
|
|
171
|
+
"""
|
|
172
|
+
name: str = Field(description="Name of the funnel")
|
|
173
|
+
description: Optional[str] = Field(default=None, description="Description of the funnel's purpose")
|
|
174
|
+
created_by: StrObjectId = Field(description="User ID who created the funnel")
|
|
175
|
+
is_active: bool = Field(default=True, description="Whether the funnel is active or archived")
|
|
176
|
+
|
|
177
|
+
preview_class: ClassVar[type[FunnelPreview]] = FunnelPreview
|
|
178
|
+
|
|
179
|
+
model_config = ConfigDict(
|
|
180
|
+
validate_by_name=True,
|
|
181
|
+
validate_by_alias=True
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class FunnelStage(CompanyAssetModel):
|
|
186
|
+
"""
|
|
187
|
+
A stage within a funnel. Stages are ordered and can have automations
|
|
188
|
+
that execute when a chat enters the stage.
|
|
189
|
+
"""
|
|
190
|
+
funnel_id: StrObjectId = Field(frozen=True, description="The funnel this stage belongs to")
|
|
191
|
+
name: str = Field(description="Name of the stage")
|
|
192
|
+
description: Optional[str] = Field(default=None, description="Description of the stage")
|
|
193
|
+
color: str = Field(default="#808080", description="Hex color for the stage (e.g., '#FFAA00')")
|
|
194
|
+
order: int = Field(ge=0, description="Position of the stage in the funnel (0-indexed)")
|
|
195
|
+
inflexion_conversion_point: bool = Field(
|
|
196
|
+
default=False,
|
|
197
|
+
description="If true, prioritized for chat assignment and sends notifications in automatic mode"
|
|
198
|
+
)
|
|
199
|
+
is_exit_stage: bool = Field(
|
|
200
|
+
default=False,
|
|
201
|
+
description="If true, moving to this stage completes the funnel for the chat"
|
|
202
|
+
)
|
|
203
|
+
automations: Automation = Field(
|
|
204
|
+
default_factory=Automation,
|
|
205
|
+
description="Automations to execute when a chat enters this stage"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
preview_class: ClassVar[type[FunnelStagePreview]] = FunnelStagePreview
|
|
209
|
+
|
|
210
|
+
model_config = ConfigDict(
|
|
211
|
+
validate_by_name=True,
|
|
212
|
+
validate_by_alias=True
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class FunnelMember(CompanyAssetModel):
|
|
217
|
+
"""
|
|
218
|
+
A user's membership in a funnel with their assigned role.
|
|
219
|
+
Determines what actions the user can perform within the funnel.
|
|
220
|
+
"""
|
|
221
|
+
name: str = Field(default="Funnel Member", description="Name for asset compatibility")
|
|
222
|
+
funnel_id: StrObjectId = Field(frozen=True, description="The funnel this membership belongs to")
|
|
223
|
+
user_id: StrObjectId = Field(frozen=True, description="The user who is a member")
|
|
224
|
+
role: FunnelMemberRole = Field(description="The user's role within the funnel")
|
|
225
|
+
|
|
226
|
+
model_config = ConfigDict(
|
|
227
|
+
validate_by_name=True,
|
|
228
|
+
validate_by_alias=True
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def can_edit(self) -> bool:
|
|
233
|
+
"""Check if the member can edit (move chats, etc.)"""
|
|
234
|
+
return self.role in (FunnelMemberRole.ADMIN, FunnelMemberRole.EDITOR)
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def can_admin(self) -> bool:
|
|
238
|
+
"""Check if the member can administer the funnel"""
|
|
239
|
+
return self.role == FunnelMemberRole.ADMIN
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ============================================================================
|
|
243
|
+
# ChatFunnel - Stored in separate chat_funnels collection
|
|
244
|
+
# This is a ChattyAssetModel (not CompanyAssetModel) because it's a chat history
|
|
245
|
+
# record rather than a company-level asset.
|
|
246
|
+
# ============================================================================
|
|
247
|
+
|
|
248
|
+
class ChatFunnel(ChattyAssetModel):
|
|
249
|
+
"""
|
|
250
|
+
Full record of a chat's journey through a funnel.
|
|
251
|
+
Stored in a separate 'chat_funnels' collection, NOT embedded in chat.
|
|
252
|
+
|
|
253
|
+
This extends ChattyAssetModel (not CompanyAssetModel) because it's a
|
|
254
|
+
chat-level record tracking funnel history, not a company-owned asset.
|
|
255
|
+
|
|
256
|
+
A new ChatFunnel is created each time a chat enters a funnel.
|
|
257
|
+
If a chat completes a funnel and enters it again, a NEW ChatFunnel is created.
|
|
258
|
+
|
|
259
|
+
The Chat document stores a lightweight ActiveFunnel for fast
|
|
260
|
+
filtering and display, with a reference to this full record.
|
|
261
|
+
"""
|
|
262
|
+
company_id: StrObjectId = Field(frozen=True, description="Company for multi-tenant isolation")
|
|
263
|
+
chat_id: StrObjectId = Field(frozen=True, description="The chat this funnel record belongs to")
|
|
264
|
+
funnel_id: StrObjectId = Field(frozen=True, description="The funnel the chat entered")
|
|
265
|
+
status: FunnelStatus = Field(default=FunnelStatus.IN_PROGRESS)
|
|
266
|
+
started_at: datetime = Field(default_factory=lambda: datetime.now(ZoneInfo("UTC")))
|
|
267
|
+
completed_at: Optional[datetime] = Field(default=None)
|
|
268
|
+
abandoned_at: Optional[datetime] = Field(default=None)
|
|
269
|
+
current_stage_id: Optional[StrObjectId] = Field(
|
|
270
|
+
default=None,
|
|
271
|
+
description="Current stage ID (None if funnel is completed/abandoned)"
|
|
272
|
+
)
|
|
273
|
+
entered_current_stage_at: Optional[datetime] = Field(
|
|
274
|
+
default=None,
|
|
275
|
+
description="When the chat entered the current stage"
|
|
276
|
+
)
|
|
277
|
+
stage_transitions: List[StageTransition] = Field(
|
|
278
|
+
default_factory=list,
|
|
279
|
+
description="Complete history of all stage transitions"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
model_config = ConfigDict(
|
|
283
|
+
validate_by_name=True,
|
|
284
|
+
validate_by_alias=True
|
|
285
|
+
)
|
|
33
286
|
|
|
34
287
|
@property
|
|
35
288
|
def is_completed(self) -> bool:
|
|
36
|
-
|
|
289
|
+
"""Check if the chat has completed the funnel"""
|
|
290
|
+
return self.status == FunnelStatus.COMPLETED and self.completed_at is not None
|
|
37
291
|
|
|
38
292
|
@property
|
|
39
293
|
def is_abandoned(self) -> bool:
|
|
40
|
-
|
|
294
|
+
"""Check if the chat has abandoned the funnel"""
|
|
295
|
+
return self.status == FunnelStatus.ABANDONED and self.abandoned_at is not None
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def is_active(self) -> bool:
|
|
299
|
+
"""Check if the chat is still active in the funnel"""
|
|
300
|
+
return self.status == FunnelStatus.IN_PROGRESS
|
|
41
301
|
|
|
42
302
|
@property
|
|
43
303
|
def time_in_funnel_seconds(self) -> int:
|
|
304
|
+
"""Calculate total time spent in the funnel"""
|
|
44
305
|
end_time = self.completed_at or self.abandoned_at or datetime.now(tz=ZoneInfo("UTC"))
|
|
45
306
|
return int((end_time - self.started_at).total_seconds())
|
|
46
307
|
|
|
47
308
|
@property
|
|
48
309
|
def time_in_current_stage_seconds(self) -> Optional[int]:
|
|
49
|
-
|
|
310
|
+
"""Calculate time spent in the current stage"""
|
|
311
|
+
if not self.entered_current_stage_at or not self.current_stage_id:
|
|
50
312
|
return None
|
|
51
313
|
return int((datetime.now(tz=ZoneInfo("UTC")) - self.entered_current_stage_at).total_seconds())
|
|
52
314
|
|
|
53
315
|
@property
|
|
54
316
|
def unique_stages_visited(self) -> int:
|
|
317
|
+
"""Count of unique stages the chat has visited"""
|
|
55
318
|
return len(set(t.to_stage_id for t in self.stage_transitions))
|
|
56
319
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
inflexion_conversion_point: bool = Field(default=False, description="If true, it'll be prioritized for chat assignment and will send notifications in automatic mode to all agents.")
|
|
62
|
-
workflow_ids: List[StrObjectId] = Field(default_factory=list)
|
|
320
|
+
@property
|
|
321
|
+
def total_transitions(self) -> int:
|
|
322
|
+
"""Total number of stage transitions"""
|
|
323
|
+
return len(self.stage_transitions)
|
|
63
324
|
|
|
325
|
+
@property
|
|
326
|
+
def regression_count(self) -> int:
|
|
327
|
+
"""Count of times the chat moved backwards in the funnel"""
|
|
328
|
+
return sum(1 for t in self.stage_transitions if t.is_regression)
|
|
64
329
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
330
|
+
@property
|
|
331
|
+
def last_transition(self) -> Optional[StageTransition]:
|
|
332
|
+
"""Get the most recent stage transition"""
|
|
333
|
+
return self.stage_transitions[-1] if self.stage_transitions else None
|
|
334
|
+
|
|
335
|
+
def record_transition(
|
|
336
|
+
self,
|
|
337
|
+
to_stage_id: StrObjectId,
|
|
338
|
+
executor_type: ExecutorType,
|
|
339
|
+
executor_id: StrObjectId,
|
|
340
|
+
from_stage_order: Optional[int] = None,
|
|
341
|
+
to_stage_order: Optional[int] = None
|
|
342
|
+
) -> StageTransition:
|
|
343
|
+
"""
|
|
344
|
+
Record a transition to a new stage.
|
|
345
|
+
Returns the created StageTransition.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
to_stage_id: The stage to transition to
|
|
349
|
+
executor_type: Who/what triggered the transition
|
|
350
|
+
executor_id: ID of the executor
|
|
351
|
+
from_stage_order: Order of current stage (for regression detection)
|
|
352
|
+
to_stage_order: Order of target stage (for regression detection)
|
|
353
|
+
"""
|
|
354
|
+
time_in_previous = self.time_in_current_stage_seconds
|
|
355
|
+
|
|
356
|
+
transition = StageTransition(
|
|
357
|
+
from_stage_id=self.current_stage_id,
|
|
358
|
+
to_stage_id=to_stage_id,
|
|
359
|
+
executor_type=executor_type,
|
|
360
|
+
executor_id=executor_id,
|
|
361
|
+
time_in_previous_stage_seconds=time_in_previous,
|
|
362
|
+
from_stage_order=from_stage_order,
|
|
363
|
+
to_stage_order=to_stage_order
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
self.stage_transitions.append(transition)
|
|
367
|
+
self.current_stage_id = to_stage_id
|
|
368
|
+
self.entered_current_stage_at = transition.transitioned_at
|
|
369
|
+
|
|
370
|
+
return transition
|
|
371
|
+
|
|
372
|
+
def complete(self) -> None:
|
|
373
|
+
"""Mark the funnel as completed"""
|
|
374
|
+
self.status = FunnelStatus.COMPLETED
|
|
375
|
+
self.completed_at = datetime.now(tz=ZoneInfo("UTC"))
|
|
376
|
+
|
|
377
|
+
def abandon(self) -> None:
|
|
378
|
+
"""Mark the funnel as abandoned"""
|
|
379
|
+
self.status = FunnelStatus.ABANDONED
|
|
380
|
+
self.abandoned_at = datetime.now(tz=ZoneInfo("UTC"))
|
|
381
|
+
|
|
382
|
+
def to_active_funnel(self, funnel_name: str, stage_name: str) -> ActiveFunnel:
|
|
383
|
+
"""
|
|
384
|
+
Create an ActiveFunnel for embedding in the Chat document.
|
|
385
|
+
Call this after creating/updating the ChatFunnel.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
funnel_name: Name of the funnel (for denormalization)
|
|
389
|
+
stage_name: Name of the current stage (for denormalization)
|
|
390
|
+
"""
|
|
391
|
+
if not self.current_stage_id or not self.entered_current_stage_at:
|
|
392
|
+
raise ValueError("Cannot create ActiveFunnel without current stage")
|
|
393
|
+
|
|
394
|
+
return ActiveFunnel(
|
|
395
|
+
chat_funnel_id=self.id,
|
|
396
|
+
funnel_id=self.funnel_id,
|
|
397
|
+
funnel_name=funnel_name,
|
|
398
|
+
current_stage_id=self.current_stage_id,
|
|
399
|
+
current_stage_name=stage_name,
|
|
400
|
+
entered_current_stage_at=self.entered_current_stage_at
|
|
401
|
+
)
|
|
@@ -1,3 +1,12 @@
|
|
|
1
1
|
from .assets import *
|
|
2
2
|
from .empresa import EmpresaModel
|
|
3
|
-
from .form_field import FormField, FormFieldPreview, CollectedData, SystemFormFields
|
|
3
|
+
from .form_field import FormField, FormFieldPreview, CollectedData, SystemFormFields
|
|
4
|
+
from .CRM.funnel import Funnel, FunnelPreview, ChatFunnel, StageTransition, FunnelStatus
|
|
5
|
+
from .integrations.product_sync_status import ProductSyncStatus
|
|
6
|
+
from .integrations.sync_status_enum import SyncStatusEnum
|
|
7
|
+
from .integrations.shopify.shopify_webhook_topics import ShopifyWebhookTopic
|
|
8
|
+
from .integrations.shopify.company_shopify_integration import ShopifyIntegration
|
|
9
|
+
from .integrations.shopify.shopify_product_sync_status import ShopifyProductSyncStatus, ShopifyProductSyncStatusEnum
|
|
10
|
+
from .integrations.tienda_nube.tienda_nube_webhook_topics import TiendaNubeWebhookTopic
|
|
11
|
+
from .integrations.tienda_nube.company_tienda_nube_integration import TiendaNubeIntegration
|
|
12
|
+
from .integrations.tienda_nube.tienda_nube_product_sync_status import TiendaNubeProductSyncStatus, TiendaNubeProductSyncStatusEnum
|
|
@@ -55,7 +55,7 @@ class SmartFollowUpDecision(BaseModel):
|
|
|
55
55
|
SmartFollowUpDecisionAction.POSTPONE_DELTA_TIME]:
|
|
56
56
|
# Postpone actions don't require messages
|
|
57
57
|
if self.messages is not None and len(self.messages) > 0:
|
|
58
|
-
raise ValueError("Messages are not allowed when action is postpone")
|
|
58
|
+
raise ValueError("Messages are not allowed when action is postpone/postponed")
|
|
59
59
|
else:
|
|
60
60
|
raise ValueError(f"Invalid action: {self.action}")
|
|
61
61
|
|
|
@@ -92,10 +92,6 @@ class ChattyAIAgentInChat(CompanyAssetModel):
|
|
|
92
92
|
default=None,
|
|
93
93
|
description="If the trigger is a user message, this will be the id of the incoming message"
|
|
94
94
|
)
|
|
95
|
-
last_reset_message_id: Optional[str] = Field(
|
|
96
|
-
default=None,
|
|
97
|
-
description="Last reset control trigger message id handled for this chat"
|
|
98
|
-
)
|
|
99
95
|
|
|
100
96
|
# Assignment metadata
|
|
101
97
|
assigned_at: datetime = Field(default_factory=lambda: datetime.now(ZoneInfo("UTC")))
|
|
@@ -16,7 +16,7 @@ class ChattyAIMode(StrEnum):
|
|
|
16
16
|
return {
|
|
17
17
|
cls.AUTONOMOUS: "El agente de IA tendrá la autonomía de conversar en tiempo real con el usuario, respetando sus instrucciones y las reglas establecidas.",
|
|
18
18
|
cls.SUGGESTIONS: "El agente de IA sugerirá sólo hará sugerencias de respuestas y seguimientos, pero no enviará mensajes ni interactuará de forma directa con el usuario.",
|
|
19
|
-
cls.COPILOT: "El agente de IA
|
|
19
|
+
cls.COPILOT: "El agente de IA hará sugerencias de respuestas y seguimientos, y responderá de forma directa únicamente aquellas preguntas especificadas en el contexto del agente, en la sección de preguntas frecuentes (FAQ).",
|
|
20
20
|
cls.OFF: "El agente de IA estará inactivo. No responderá al usuario ni hará sugerencias."
|
|
21
21
|
}[mode]
|
|
22
22
|
|
|
@@ -26,7 +26,7 @@ class ChattyAIMode(StrEnum):
|
|
|
26
26
|
mode_description = {
|
|
27
27
|
cls.AUTONOMOUS: "Only answer based on the context and rules provided. Do not improvise or make up information. If you can't handle the question, escalate to a human.",
|
|
28
28
|
cls.SUGGESTIONS: "You're only going to be making suggestions, all your messages will be reviewd by a human and you should add the reasoning to your chain of thought. If the user message is not worth answering, you can use the 'skip' action in your output. If the user message is worth answering, you NEED to use the 'suggest' action in your output. ",
|
|
29
|
-
cls.COPILOT: "You're in COPILOT mode
|
|
29
|
+
cls.COPILOT: "You're in a COPILOT mode, so you'll be making suggestions on general inquires, and answer only questions specified in the FAQ section. So, for answers which you're creating based on the context, you'll be making a suggestions, and need to add the 'suggestion' subtype in each message. When asked about a question included in the FAQ, you're allowed to adapt the answer to the user's question, but you're not allowed to improvise or make up information, and you'll set the 'text' subtype in the messages.",
|
|
30
30
|
cls.OFF: ""
|
|
31
31
|
}[mode]
|
|
32
32
|
return intro + "\n\n" + mode_description
|
|
@@ -25,7 +25,6 @@ class GetChatWithPromptResponse(BaseModel):
|
|
|
25
25
|
"messages": self.messages,
|
|
26
26
|
"n8n_agent_type": self.chatty_ai_agent.n8n_workspace_agent_type.value if self.chatty_ai_agent else None,
|
|
27
27
|
"n8n_agent_type_parameters": self.chatty_ai_agent.n8n_workspace_agent_type_parameteres.model_dump() if self.chatty_ai_agent else None,
|
|
28
|
-
"ai_agent_id": self.chatty_ai_agent.id if self.chatty_ai_agent else None,
|
|
29
28
|
"phone_number": self.chat.client.get_waid() if self.chat else None,
|
|
30
29
|
"chain_of_thought_id": self.chain_of_thought_id,
|
|
31
30
|
"trigger_id": self.trigger_id
|
|
@@ -5,17 +5,11 @@ Defines the data collection, acceptance criteria, and destination actions
|
|
|
5
5
|
for qualifying/disqualifying users.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from letschatty.models.company.assets.automation import Automation
|
|
9
8
|
from pydantic import BaseModel, Field
|
|
10
9
|
from typing import Optional, List
|
|
11
10
|
from enum import StrEnum
|
|
12
11
|
from letschatty.models.utils.types.identifier import StrObjectId
|
|
13
12
|
|
|
14
|
-
class PostToExternalApiConfig(BaseModel):
|
|
15
|
-
"""Placeholder provisorio"""
|
|
16
|
-
url: str = Field(description="The URL to post to")
|
|
17
|
-
method: str = Field(description="The HTTP method to use")
|
|
18
|
-
api_key: str
|
|
19
13
|
|
|
20
14
|
class PreQualifyDestination(StrEnum):
|
|
21
15
|
"""
|
|
@@ -24,22 +18,9 @@ class PreQualifyDestination(StrEnum):
|
|
|
24
18
|
SUBSCRIBE_TO_LAUNCH = "subscribe_to_launch" # Subscribe to launch + welcome kit
|
|
25
19
|
CALENDAR_SCHEDULER = "calendar_scheduler" # Allow AI agent to schedule meetings
|
|
26
20
|
ESCALATE = "escalate" # Escalate to human
|
|
27
|
-
AUTO_ASSIGN_HUMAN_AGENT = "auto_assign_human_agent" # Auto-assign human agent
|
|
28
21
|
CUSTOM_MESSAGE = "custom_message" # Send a custom message
|
|
29
|
-
AUTO_ASSIGN_HUMAN_AGENT = "auto_assign_human_agent" #
|
|
30
22
|
CONTINUE = "continue" # Continue normal AI agent flow
|
|
31
23
|
NONE = "none" # Do nothing
|
|
32
|
-
ARCHIVE = "archive" # Archive chat
|
|
33
|
-
POST_TO_EXTERNAL_API = "post_to_external_api" # Post result to external API
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
class PostToExternalApiConfig(BaseModel):
|
|
37
|
-
"""
|
|
38
|
-
Configuration for posting pre-qualification results to an external API.
|
|
39
|
-
"""
|
|
40
|
-
url: str = Field(description="Target URL for the external API")
|
|
41
|
-
method: str = Field(default="POST", description="HTTP method to use")
|
|
42
|
-
api_key: Optional[str] = Field(default=None, description="Optional API key for authentication")
|
|
43
24
|
|
|
44
25
|
|
|
45
26
|
class PreQualifyFormField(BaseModel):
|
|
@@ -87,16 +68,6 @@ class PreQualifyConfig(BaseModel):
|
|
|
87
68
|
default=None,
|
|
88
69
|
description="Custom message to send when user does NOT qualify (if destination is custom_message or escalate)"
|
|
89
70
|
)
|
|
90
|
-
post_to_external_api : Optional[PostToExternalApiConfig] = Field(
|
|
91
|
-
default=None,
|
|
92
|
-
description="Configuration for posting to an external API"
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
# Optional external API destination config
|
|
96
|
-
post_to_external_api: Optional[PostToExternalApiConfig] = Field(
|
|
97
|
-
default=None,
|
|
98
|
-
description="Config for POST_TO_EXTERNAL_API destination"
|
|
99
|
-
)
|
|
100
71
|
|
|
101
72
|
@property
|
|
102
73
|
def has_form_fields(self) -> bool:
|
|
@@ -137,3 +108,4 @@ class PreQualifyConfig(BaseModel):
|
|
|
137
108
|
original_len = len(self.form_fields)
|
|
138
109
|
self.form_fields = [f for f in self.form_fields if f.field_key != field_key]
|
|
139
110
|
return len(self.form_fields) < original_len
|
|
111
|
+
|