embedingHF commited on
Commit
ae677bb
·
verified ·
1 Parent(s): 6db6290

Upload folder using huggingface_hub

Browse files
Files changed (49) hide show
  1. .idea/AI_chatbot.iml +11 -0
  2. .idea/inspectionProfiles/profiles_settings.xml +6 -0
  3. .idea/modules.xml +8 -0
  4. .idea/workspace.xml +42 -0
  5. __init__.py +0 -0
  6. __pycache__/__init__.cpython-312.pyc +0 -0
  7. __pycache__/admin.cpython-312.pyc +0 -0
  8. __pycache__/apps.cpython-312.pyc +0 -0
  9. __pycache__/consumers.cpython-312.pyc +0 -0
  10. __pycache__/greeting_question.cpython-312.pyc +0 -0
  11. __pycache__/models.cpython-312.pyc +0 -0
  12. __pycache__/serializers.cpython-312.pyc +0 -0
  13. __pycache__/services.cpython-312.pyc +0 -0
  14. __pycache__/urls.cpython-312.pyc +0 -0
  15. __pycache__/views.cpython-312.pyc +0 -0
  16. admin.py +68 -0
  17. apps.py +77 -0
  18. consumers.py +510 -0
  19. greeting_question.py +44 -0
  20. management/__init__.py +0 -0
  21. management/__pycache__/__init__.cpython-312.pyc +0 -0
  22. management/commands/__init__.py +0 -0
  23. management/commands/__pycache__/__init__.cpython-312.pyc +0 -0
  24. management/commands/__pycache__/setup_default_qa.cpython-312.pyc +0 -0
  25. management/commands/__pycache__/setup_global_qa.cpython-312.pyc +0 -0
  26. management/commands/__pycache__/setup_master_questions.cpython-312.pyc +0 -0
  27. management/commands/__pycache__/test_model.cpython-312.pyc +0 -0
  28. management/commands/__pycache__/websocket_test.cpython-312.pyc +0 -0
  29. management/commands/setup_global_qa.py +65 -0
  30. management/commands/setup_master_questions.py +42 -0
  31. migrations/0001_initial.py +160 -0
  32. migrations/__init__.py +1 -0
  33. migrations/__pycache__/0001_initial.cpython-312.pyc +0 -0
  34. migrations/__pycache__/__init__.cpython-312.pyc +0 -0
  35. models.py +190 -0
  36. models/fine_tuned_bilingual_model_v2/1_Pooling/config.json +5 -0
  37. models/fine_tuned_bilingual_model_v2/README.md +341 -0
  38. models/fine_tuned_bilingual_model_v2/config.json +30 -0
  39. models/fine_tuned_bilingual_model_v2/config_sentence_transformers.json +14 -0
  40. models/fine_tuned_bilingual_model_v2/model.safetensors +3 -0
  41. models/fine_tuned_bilingual_model_v2/modules.json +20 -0
  42. models/fine_tuned_bilingual_model_v2/sentence_bert_config.json +10 -0
  43. models/fine_tuned_bilingual_model_v2/tokenizer.json +0 -0
  44. models/fine_tuned_bilingual_model_v2/tokenizer_config.json +23 -0
  45. serializers.py +54 -0
  46. services.py +488 -0
  47. tests.py +3 -0
  48. urls.py +27 -0
  49. views.py +813 -0
.idea/AI_chatbot.iml ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <module type="PYTHON_MODULE" version="4">
3
+ <component name="NewModuleRootManager">
4
+ <content url="file://$MODULE_DIR$" />
5
+ <orderEntry type="jdk" jdkName="Python 3.10 (AI_chatbot)" jdkType="Python SDK" />
6
+ <orderEntry type="sourceFolder" forTests="false" />
7
+ </component>
8
+ <component name="TestRunnerService">
9
+ <option name="PROJECT_TEST_RUNNER" value="py.test" />
10
+ </component>
11
+ </module>
.idea/inspectionProfiles/profiles_settings.xml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ <component name="InspectionProjectProfileManager">
2
+ <settings>
3
+ <option name="USE_PROJECT_PROFILE" value="false" />
4
+ <version value="1.0" />
5
+ </settings>
6
+ </component>
.idea/modules.xml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="ProjectModuleManager">
4
+ <modules>
5
+ <module fileurl="file://$PROJECT_DIR$/.idea/AI_chatbot.iml" filepath="$PROJECT_DIR$/.idea/AI_chatbot.iml" />
6
+ </modules>
7
+ </component>
8
+ </project>
.idea/workspace.xml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="ChangeListManager">
4
+ <list default="true" id="aed0b69f-8b78-421a-90c0-9e8f8d7fd214" name="Changes" comment="" />
5
+ <option name="SHOW_DIALOG" value="false" />
6
+ <option name="HIGHLIGHT_CONFLICTS" value="true" />
7
+ <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
8
+ <option name="LAST_RESOLUTION" value="IGNORE" />
9
+ </component>
10
+ <component name="ProjectColorInfo"><![CDATA[{
11
+ "customColor": "",
12
+ "associatedIndex": 5
13
+ }]]></component>
14
+ <component name="ProjectId" id="3D4uuD9kg3KtHzbudGUI6I5KUS4" />
15
+ <component name="ProjectViewState">
16
+ <option name="hideEmptyMiddlePackages" value="true" />
17
+ <option name="showLibraryContents" value="true" />
18
+ </component>
19
+ <component name="PropertiesComponent"><![CDATA[{
20
+ "keyToString": {
21
+ "ModuleVcsDetector.initialDetectionPerformed": "true",
22
+ "RunOnceActivity.ShowReadmeOnStart": "true"
23
+ }
24
+ }]]></component>
25
+ <component name="SharedIndexes">
26
+ <attachedChunks>
27
+ <set>
28
+ <option value="bundled-python-sdk-ca5e2b39c7df-6e1f45a539f7-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-253.29346.308" />
29
+ </set>
30
+ </attachedChunks>
31
+ </component>
32
+ <component name="TaskManager">
33
+ <task active="true" id="Default" summary="Default task">
34
+ <changelist id="aed0b69f-8b78-421a-90c0-9e8f8d7fd214" name="Changes" comment="" />
35
+ <created>1777553886147</created>
36
+ <option name="number" value="Default" />
37
+ <option name="presentableId" value="Default" />
38
+ <updated>1777553886147</updated>
39
+ </task>
40
+ <servers />
41
+ </component>
42
+ </project>
__init__.py ADDED
File without changes
__pycache__/__init__.cpython-312.pyc ADDED
Binary file (146 Bytes). View file
 
__pycache__/admin.cpython-312.pyc ADDED
Binary file (3.92 kB). View file
 
__pycache__/apps.cpython-312.pyc ADDED
Binary file (3.6 kB). View file
 
__pycache__/consumers.cpython-312.pyc ADDED
Binary file (25.6 kB). View file
 
__pycache__/greeting_question.cpython-312.pyc ADDED
Binary file (3.19 kB). View file
 
__pycache__/models.cpython-312.pyc ADDED
Binary file (10.1 kB). View file
 
__pycache__/serializers.cpython-312.pyc ADDED
Binary file (3.35 kB). View file
 
__pycache__/services.cpython-312.pyc ADDED
Binary file (22 kB). View file
 
__pycache__/urls.cpython-312.pyc ADDED
Binary file (1.58 kB). View file
 
__pycache__/views.cpython-312.pyc ADDED
Binary file (32.8 kB). View file
 
admin.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from django.contrib import admin
2
+ from .models import MasterQuestion, PropertyQA, AgencyAutoChatSetting, PropertyAutoChatState, GlobalQA, PropertyCustomQA
3
+
4
+ @admin.register(GlobalQA)
5
+ class GlobalQAAdmin(admin.ModelAdmin):
6
+ list_display = ['question', 'answer_preview', 'language', 'priority', 'is_active']
7
+ list_filter = ['language', 'is_active', 'priority']
8
+ search_fields = ['question', 'answer']
9
+ list_editable = ['priority', 'is_active']
10
+
11
+ def answer_preview(self, obj):
12
+ return obj.answer[:50]
13
+ answer_preview.short_description = 'Answer'
14
+
15
+ fieldsets = (
16
+ ('Question & Answer', {
17
+ 'fields': ('question', 'answer', 'language')
18
+ }),
19
+ ('Settings', {
20
+ 'fields': ('priority', 'is_active'),
21
+ 'description': 'Higher priority = checked first for matching'
22
+ }),
23
+ )
24
+
25
+ @admin.register(MasterQuestion)
26
+ class MasterQuestionAdmin(admin.ModelAdmin):
27
+ list_display = ['question', 'order', 'is_active']
28
+ list_editable = ['order', 'is_active']
29
+ search_fields = ['question']
30
+
31
+ @admin.register(PropertyQA)
32
+ class PropertyQAAdmin(admin.ModelAdmin):
33
+ list_display = ['property', 'question_preview', 'answer_preview']
34
+ list_filter = ['agency']
35
+ search_fields = ['answer']
36
+
37
+ def question_preview(self, obj):
38
+ return obj.question_text[:50]
39
+ question_preview.short_description = 'Question'
40
+
41
+ def answer_preview(self, obj):
42
+ return obj.answer[:50]
43
+ answer_preview.short_description = 'Answer'
44
+
45
+ @admin.register(AgencyAutoChatSetting)
46
+ class AgencyAutoChatSettingAdmin(admin.ModelAdmin):
47
+ list_display = ['agency', 'is_enabled', 'delay_seconds', 'confidence_threshold']
48
+ list_filter = ['is_enabled']
49
+ list_editable = ['delay_seconds', 'confidence_threshold']
50
+
51
+ @admin.register(PropertyAutoChatState)
52
+ class PropertyAutoChatStateAdmin(admin.ModelAdmin):
53
+ list_display = ['property', 'is_auto_chat_enabled', 'total_auto_replies', 'last_auto_reply_at']
54
+ list_filter = ['is_auto_chat_enabled']
55
+ readonly_fields = ['total_auto_replies', 'last_auto_reply_at']
56
+
57
+ @admin.register(PropertyCustomQA)
58
+ class PropertyCustomQAAdmin(admin.ModelAdmin):
59
+ list_display = ['property', 'agency', 'order', 'question_preview', 'is_active']
60
+ list_filter = ['agency', 'is_active', 'property']
61
+ search_fields = ['question', 'answer']
62
+ list_editable = ['is_active']
63
+
64
+ def question_preview(self, obj):
65
+ return obj.question[:60]
66
+ question_preview.short_description = 'Question'
67
+
68
+ readonly_fields = ['question_embedding']
apps.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import atexit
3
+ import os
4
+ from django.apps import AppConfig, apps
5
+ from django.db import connection
6
+ from django.core.exceptions import AppRegistryNotReady
7
+
8
+ class AiChatbotConfig(AppConfig):
9
+ default_auto_field = 'django.db.models.BigAutoField'
10
+ name = 'ai_chatbot'
11
+
12
+ def ready(self):
13
+ # Prevent double execution in runserver
14
+ if 'runserver' in sys.argv and '--noreload' not in sys.argv:
15
+ if os.environ.get('RUN_MAIN') != 'true':
16
+ return
17
+
18
+ # ✅ FIX: Check if tables exist before syncing
19
+ try:
20
+ from django.db.utils import OperationalError, ProgrammingError
21
+
22
+ # Check if master_question table exists
23
+ with connection.cursor() as cursor:
24
+ cursor.execute("""
25
+ SELECT EXISTS (
26
+ SELECT FROM information_schema.tables
27
+ WHERE table_name = 'ai_chatbot_masterquestion'
28
+ );
29
+ """)
30
+ table_exists = cursor.fetchone()[0]
31
+
32
+ if not table_exists:
33
+ print("⚠️ Database tables not ready yet. Skipping auto-sync.")
34
+ return
35
+
36
+ except (OperationalError, ProgrammingError) as e:
37
+ print(f"⚠️ Database not ready: {e}. Skipping auto-sync.")
38
+ return
39
+
40
+ from .services import EmbeddingService
41
+ from .models import GlobalQA, MasterQuestion
42
+ from .greeting_question import SYSTEM_GREETINGS
43
+
44
+ try:
45
+ service = EmbeddingService()
46
+ print("✓ AI Model loaded for Auto-Sync")
47
+
48
+ # # 1. Sync Master Questions
49
+ # for q_data in SYSTEM_QUESTIONS:
50
+ # MasterQuestion.objects.get_or_create(
51
+ # question=q_data['question'],
52
+ # defaults={'order': q_data['order']}
53
+ # )
54
+
55
+ # 2. Sync Global Greetings
56
+ for g_data in SYSTEM_GREETINGS:
57
+ if not GlobalQA.objects.filter(question=g_data['question']).exists():
58
+ print(f"→ Generating vector for greeting: {g_data['question']}")
59
+ vector = service.generate_embedding(g_data['question'])
60
+ GlobalQA.objects.create(
61
+ question=g_data['question'],
62
+ answer=g_data['answer'],
63
+ question_embedding=vector,
64
+ language=g_data['language'],
65
+ priority=g_data['priority']
66
+ )
67
+ print("✅ System AI Knowledge Sync Complete")
68
+
69
+ # Register thread cleanup on exit
70
+ atexit.register(EmbeddingService.cleanup_thread)
71
+
72
+ except Exception as e:
73
+ print(f"✗ Auto-Sync failed: {e}")
74
+
75
+ def get_property_info(property_id):
76
+ PropertyModel = apps.get_model('Property', 'Property')
77
+ return PropertyModel.objects.get(id=property_id)
consumers.py ADDED
@@ -0,0 +1,510 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ai_chatbot/consumers.py
2
+ import json
3
+ import logging
4
+ import asyncio
5
+ from datetime import datetime
6
+ from typing import Dict, Optional
7
+
8
+ from asgiref.sync import sync_to_async
9
+ from channels.generic.websocket import AsyncWebsocketConsumer
10
+ from django.db.models import Q
11
+ from django.utils import timezone
12
+
13
+ from Chat.models import ChatMessage
14
+ from ai_chatbot.services import AutoChatService
15
+ from ai_chatbot.models import AgencyAutoChatSetting, PropertyAutoChatState
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class AIAutoChatConsumer(AsyncWebsocketConsumer):
21
+ """
22
+ Enhanced WebSocket consumer with proper per-property timer management
23
+ Fixes: Race conditions, memory leaks, hardcoded delays
24
+ """
25
+
26
+ def __init__(self, *args, **kwargs):
27
+ super().__init__(*args, **kwargs)
28
+ self.auto_reply_service = AutoChatService()
29
+ # Per-property timers
30
+ self.auto_reply_tasks: Dict[str, asyncio.Task] = {}
31
+ self.last_client_message_at: Dict[str, datetime] = {}
32
+ self.last_agency_reply_at: Dict[str, datetime] = {} # ✅ FIXED: Added
33
+ self.current_chat_property = None
34
+
35
+ async def connect(self):
36
+ """Handle WebSocket connection with authentication"""
37
+ user = self.scope.get('user', None)
38
+ if user and user.is_authenticated:
39
+ self.user = user
40
+ self.user_channel_name = f'ws_channel_layer_{self.user.id}'
41
+
42
+ await self.channel_layer.group_add(
43
+ self.user_channel_name,
44
+ self.channel_name
45
+ )
46
+ await self.accept()
47
+
48
+ await self.send(text_data=json.dumps({
49
+ 'type': 'CONNECTION_SUCCESS',
50
+ 'message': {
51
+ 'status': True,
52
+ 'message': 'Connected to AI Chat!',
53
+ 'status_code': 200
54
+ }
55
+ }))
56
+ logger.info(f"✅ User {self.user.id} connected to AI Chat WebSocket")
57
+ else:
58
+ await self.accept()
59
+ await self.send(text_data=json.dumps({
60
+ 'type': 'CONNECTION_SUCCESS',
61
+ 'message': {
62
+ 'status': False,
63
+ 'message': 'Authentication required',
64
+ 'status_code': 401
65
+ }
66
+ }))
67
+ logger.warning("❌ Unauthenticated WebSocket connection attempt")
68
+
69
+ async def disconnect(self, close_code):
70
+ """Clean up all timers on disconnect"""
71
+ # Cancel all pending AI timers for this connection
72
+ for property_id, task in self.auto_reply_tasks.items():
73
+ if task and not task.done():
74
+ task.cancel()
75
+ try:
76
+ await task
77
+ except asyncio.CancelledError:
78
+ pass
79
+ logger.info(f"🛑 Cancelled AI timer for property {property_id}")
80
+
81
+ # Remove from channel group
82
+ if hasattr(self, 'user_channel_name'):
83
+ await self.channel_layer.group_discard(
84
+ self.user_channel_name,
85
+ self.channel_name
86
+ )
87
+
88
+ logger.info(f"👋 User {getattr(self, 'user', 'Unknown')} disconnected")
89
+
90
+ async def receive(self, text_data):
91
+ """Handle incoming WebSocket messages"""
92
+ try:
93
+ data = json.loads(text_data)
94
+ msg_type = data.get('type')
95
+
96
+ if msg_type == 'NEW_CHAT_MESSAGE':
97
+ await self.process_message_with_ai(data)
98
+ elif msg_type == 'MARK_AS_READ_CHAT':
99
+ await self.mark_as_read_chat(data.get('chat_id'))
100
+ else:
101
+ logger.warning(f"Unknown message type: {msg_type}")
102
+
103
+ except json.JSONDecodeError:
104
+ logger.error("Invalid JSON received")
105
+ except Exception as e:
106
+ logger.error(f"Error in receive: {e}", exc_info=True)
107
+ await self.send(text_data=json.dumps({
108
+ 'type': 'ERROR',
109
+ 'message': {'status': False, 'message': 'Server error', 'status_code': 500}
110
+ }))
111
+
112
+ async def process_message_with_ai(self, message_data):
113
+ """
114
+ Process message with per-property timers and idempotency
115
+ """
116
+ chat_text = message_data.get('message', '')
117
+ property_id = str(message_data.get('property_id'))
118
+ other_user_id = message_data.get('user_id')
119
+
120
+ if not property_id:
121
+ logger.error("No property_id in message")
122
+ return
123
+
124
+ # Store current property for this message
125
+ self.current_chat_property = property_id
126
+
127
+ # Create chat and message (sync operations in thread pool)
128
+ chat = await self.get_or_create_chat(other_user_id, property_id)
129
+ if not chat:
130
+ logger.error(f"Failed to create/get chat for property {property_id}")
131
+ return
132
+
133
+ msg = await self.create_chat_message(chat, chat_text, property_id)
134
+ serialized = await self.serialize_message(msg)
135
+
136
+ # Identify agency (property owner)
137
+ agency_user = await self.get_agency_from_property(property_id)
138
+ is_current_user_agency = (str(self.user.id) == str(agency_user.id)) if agency_user else False
139
+
140
+ # Cancel ONLY this property's timer
141
+ if property_id in self.auto_reply_tasks:
142
+ old_task = self.auto_reply_tasks[property_id]
143
+ if old_task and not old_task.done():
144
+ old_task.cancel()
145
+ logger.info(f"🔄 Cancelled existing AI timer for property {property_id}")
146
+
147
+ if not is_current_user_agency:
148
+ # CLIENT sent message → Start AI timer
149
+ self.last_client_message_at[property_id] = timezone.now()
150
+
151
+ # Start new timer for this property only
152
+ task = asyncio.create_task(
153
+ self.handle_ai_delay(
154
+ str(chat.id),
155
+ property_id,
156
+ chat_text,
157
+ str(agency_user.id) if agency_user else None,
158
+ msg.id if msg else None
159
+ )
160
+ )
161
+ self.auto_reply_tasks[property_id] = task
162
+ logger.info(f"⏰ Started AI timer for property {property_id} (client message)")
163
+ else:
164
+ # AGENCY replied → Cancel AI for this property
165
+ if property_id in self.auto_reply_tasks:
166
+ old_task = self.auto_reply_tasks[property_id]
167
+ if old_task and not old_task.done():
168
+ old_task.cancel()
169
+ del self.auto_reply_tasks[property_id]
170
+ logger.info(f"✅ Agency replied, cancelled AI timer for property {property_id}")
171
+
172
+ # Update last agency reply time
173
+ self.last_agency_reply_at[property_id] = timezone.now()
174
+ await self.update_last_agency_reply(property_id)
175
+
176
+ # Broadcast the message to both participants
177
+ await self.broadcast_message(chat, other_user_id, serialized, property_id, is_ai=False)
178
+
179
+ async def handle_ai_delay(self, chat_id: str, property_id: str, client_message: str,
180
+ agency_id: Optional[str], client_message_id: Optional[int] = None):
181
+ """
182
+ Use configured delay from agency settings with idempotency
183
+ """
184
+ try:
185
+ # Get agency's configured delay
186
+ delay_seconds = 30 # Default fallback
187
+
188
+ if agency_id:
189
+ try:
190
+ agency_settings = await sync_to_async(
191
+ AgencyAutoChatSetting.objects.get
192
+ )(agency_id=agency_id)
193
+ delay_seconds = agency_settings.delay_seconds
194
+ logger.info(f"⏱️ Using delay {delay_seconds}s for agency {agency_id}")
195
+ except AgencyAutoChatSetting.DoesNotExist:
196
+ logger.warning(f"No settings found for agency {agency_id}, using default 30s")
197
+
198
+ # Wait for configured delay
199
+ await asyncio.sleep(delay_seconds)
200
+
201
+ # Idempotency check - did agency already reply?
202
+ has_agency_replied = await self.check_agency_replied_after_message(
203
+ chat_id, client_message_id
204
+ )
205
+
206
+ if has_agency_replied:
207
+ logger.info(f"🛡️ Agency replied during delay, skipping AI for property {property_id}")
208
+ return
209
+
210
+ # Check if AI already replied to this message
211
+ already_replied = await self.check_ai_already_replied(chat_id, client_message_id)
212
+
213
+ if already_replied:
214
+ logger.info(f"🛡️ AI already replied, skipping duplicate for property {property_id}")
215
+ return
216
+
217
+ # Check if auto-chat is still enabled
218
+ is_auto_enabled = await self.is_auto_chat_enabled(property_id)
219
+ if not is_auto_enabled:
220
+ logger.info(f"🛡️ Auto-chat disabled for property {property_id}, skipping AI")
221
+ return
222
+
223
+ # Generate AI response
224
+ reply = await sync_to_async(self.auto_reply_service.generate_auto_reply)(
225
+ client_message, property_id, chat_id
226
+ )
227
+
228
+ if reply and reply.get('answer'):
229
+ # Save AI message as agency
230
+ if agency_id:
231
+ ai_msg = await self.create_ai_message(
232
+ chat_id, reply['answer'], agency_id, property_id
233
+ )
234
+
235
+ # Serialize and broadcast
236
+ serialized_ai = await self.serialize_message(ai_msg)
237
+ await self.broadcast_message(
238
+ ai_msg.chat, None, serialized_ai, property_id, is_ai=True
239
+ )
240
+
241
+ # Update auto-chat stats if property match
242
+ if reply.get('type') == 'property':
243
+ await self.update_auto_chat_stats(property_id)
244
+
245
+ logger.info(f"🤖 AI auto-reply sent for property {property_id} | Type: {reply.get('type')} | Confidence: {reply.get('confidence', 0):.2f}")
246
+ else:
247
+ logger.error(f"Cannot send AI reply: No agency_id for property {property_id}")
248
+ else:
249
+ logger.info(f"⚠️ No AI reply generated for property {property_id}")
250
+
251
+ except asyncio.CancelledError:
252
+ logger.info(f"🛑 AI timer cancelled for property {property_id} (agency replied)")
253
+ raise
254
+ except Exception as e:
255
+ logger.error(f"❌ AI delay task error for property {property_id}: {e}", exc_info=True)
256
+
257
+ # ========== Helper Methods ==========
258
+
259
+ @sync_to_async
260
+ def get_agency_from_property(self, property_id: str):
261
+ """Get agency (property owner)"""
262
+ try:
263
+ from Property.models import Property
264
+ prop = Property.objects.select_related('user').get(id=property_id)
265
+ return prop.user
266
+ except Exception as e:
267
+ logger.error(f"Error getting agency: {e}")
268
+ return None
269
+
270
+ @sync_to_async
271
+ def get_or_create_chat(self, user_id: str, property_id: str):
272
+ """Get or create chat between users"""
273
+ from Authentication.models import User
274
+ from Chat.models import Chat
275
+ from Property.models import Property
276
+
277
+ try:
278
+ other_user = User.objects.get(id=user_id)
279
+ prop = Property.objects.filter(id=property_id).first()
280
+
281
+ # Find existing chat
282
+ chat = Chat.objects.filter(
283
+ Q(created_by=self.user, participants=other_user, prop=prop) |
284
+ Q(created_by=other_user, participants=self.user, prop=prop)
285
+ ).first()
286
+
287
+ if not chat:
288
+ chat = Chat.objects.create(created_by=self.user, prop=prop)
289
+ chat.participants.add(self.user, other_user)
290
+
291
+ # Create system message
292
+ ChatMessage.objects.create(
293
+ chat=chat,
294
+ user=self.user,
295
+ msg_type='Notification',
296
+ text=f'{self.user.user_full_name} started a new chat.',
297
+ prop=prop
298
+ )
299
+
300
+ return chat
301
+ except User.DoesNotExist:
302
+ logger.error(f"User {user_id} not found")
303
+ return None
304
+ except Exception as e:
305
+ logger.error(f"Error getting/creating chat: {e}")
306
+ return None
307
+
308
+ @sync_to_async
309
+ def create_chat_message(self, chat, text: str, property_id: str):
310
+ """Create a new chat message"""
311
+ from Chat.models import ChatMessage
312
+ from Property.models import Property
313
+
314
+ prop = Property.objects.filter(id=property_id).first()
315
+ msg = ChatMessage.objects.create(
316
+ chat=chat,
317
+ user=self.user,
318
+ text=text,
319
+ prop=prop
320
+ )
321
+ msg.read_by.add(self.user)
322
+
323
+ chat.last_message = msg
324
+ chat.save(update_fields=['last_message'])
325
+
326
+ return msg
327
+
328
+ @sync_to_async
329
+ def create_ai_message(self, chat_id: str, text: str, agency_id: str, property_id: str):
330
+ """Create AI message as agency"""
331
+ from Authentication.models import User
332
+ from Chat.models import Chat, ChatMessage
333
+ from Property.models import Property
334
+
335
+ chat = Chat.objects.get(id=chat_id)
336
+ agency = User.objects.get(id=agency_id)
337
+ prop = Property.objects.filter(id=property_id).first()
338
+
339
+ msg = ChatMessage.objects.create(
340
+ chat=chat,
341
+ user=agency,
342
+ text=text,
343
+ prop=prop,
344
+ msg_type='AI' # Mark as AI-generated
345
+ )
346
+
347
+ chat.last_message = msg
348
+ chat.save(update_fields=['last_message'])
349
+
350
+ return msg
351
+
352
+ @sync_to_async
353
+ def serialize_message(self, msg):
354
+ """Serialize message for WebSocket response"""
355
+ from Chat import serializers
356
+ return serializers.SocketChatMessageSerializer(msg).data
357
+
358
+ @sync_to_async
359
+ def mark_as_read_chat(self, chat_id: str):
360
+ """Mark all messages in chat as read"""
361
+ from Chat.models import ChatMessage
362
+
363
+ try:
364
+ msgs = ChatMessage.objects.filter(chat__id=chat_id).exclude(read_by=self.user)
365
+ count = 0
366
+ for msg in msgs:
367
+ msg.read_by.add(self.user)
368
+ count += 1
369
+ logger.info(f"📖 User {self.user.id} marked {count} messages as read in chat {chat_id}")
370
+ return True
371
+ except Exception as e:
372
+ logger.error(f"Error marking chat read: {e}")
373
+ return False
374
+
375
+ @sync_to_async
376
+ def update_last_agency_reply(self, property_id: str):
377
+ """Update last agency reply timestamp"""
378
+ try:
379
+ state, created = PropertyAutoChatState.objects.get_or_create(
380
+ property_id=property_id
381
+ )
382
+ state.last_agency_reply_at = timezone.now()
383
+ state.save(update_fields=['last_agency_reply_at'])
384
+ except Exception as e:
385
+ logger.error(f"Error updating agency reply time: {e}")
386
+
387
+ @sync_to_async
388
+ def update_auto_chat_stats(self, property_id: str):
389
+ """Update auto-chat statistics"""
390
+ try:
391
+ state = PropertyAutoChatState.objects.get(property_id=property_id)
392
+ state.total_auto_replies += 1
393
+ state.last_auto_reply_at = timezone.now()
394
+ state.save(update_fields=['total_auto_replies', 'last_auto_reply_at'])
395
+ except PropertyAutoChatState.DoesNotExist:
396
+ PropertyAutoChatState.objects.create(
397
+ property_id=property_id,
398
+ total_auto_replies=1,
399
+ last_auto_reply_at=timezone.now()
400
+ )
401
+ except Exception as e:
402
+ logger.error(f"Error updating stats: {e}")
403
+
404
+ @sync_to_async
405
+ def is_auto_chat_enabled(self, property_id: str) -> bool:
406
+ """Check if auto-chat is enabled for property"""
407
+ try:
408
+ state = PropertyAutoChatState.objects.select_related('property__user').get(
409
+ property_id=property_id
410
+ )
411
+ if not state.is_auto_chat_enabled:
412
+ return False
413
+
414
+ # Also check agency setting
415
+ if state.property and state.property.user:
416
+ try:
417
+ agency_setting = AgencyAutoChatSetting.objects.get(
418
+ agency=state.property.user
419
+ )
420
+ return agency_setting.is_enabled
421
+ except AgencyAutoChatSetting.DoesNotExist:
422
+ return False
423
+ return False
424
+ except PropertyAutoChatState.DoesNotExist:
425
+ return False
426
+ except Exception as e:
427
+ logger.error(f"Error checking auto-chat enabled: {e}")
428
+ return False
429
+
430
+ @sync_to_async
431
+ def check_agency_replied_after_message(self, chat_id: str, client_message_id: int) -> bool:
432
+ """Check if agency replied after client's message"""
433
+ from Chat.models import ChatMessage
434
+
435
+ if not client_message_id:
436
+ return False
437
+
438
+ try:
439
+ client_msg = ChatMessage.objects.get(id=client_message_id)
440
+ # Check if any agency message came after this client message
441
+ agency_reply = ChatMessage.objects.filter(
442
+ chat_id=chat_id,
443
+ created_at__gt=client_msg.created_at
444
+ ).exclude(user_id=client_msg.user_id).exists()
445
+
446
+ return agency_reply
447
+ except ChatMessage.DoesNotExist:
448
+ return False
449
+ except Exception as e:
450
+ logger.error(f"Error checking agency reply: {e}")
451
+ return False
452
+
453
+ @sync_to_async
454
+ def check_ai_already_replied(self, chat_id: str, client_message_id: int) -> bool:
455
+ """Check if AI already replied to this message"""
456
+ from Chat.models import ChatMessage
457
+
458
+ if not client_message_id:
459
+ return False
460
+
461
+ try:
462
+ client_msg = ChatMessage.objects.get(id=client_message_id)
463
+ # Check for AI message after client message
464
+ ai_reply = ChatMessage.objects.filter(
465
+ chat_id=chat_id,
466
+ created_at__gt=client_msg.created_at,
467
+ msg_type='AI'
468
+ ).exists()
469
+
470
+ return ai_reply
471
+ except ChatMessage.DoesNotExist:
472
+ return False
473
+ except Exception as e:
474
+ logger.error(f"Error checking AI reply: {e}")
475
+ return False
476
+
477
+ async def broadcast_message(self, chat, other_user_id, serialized, property_id, is_ai=False):
478
+ """Send message to both participants"""
479
+ participants = await sync_to_async(list)(chat.participants.all())
480
+
481
+ for participant in participants:
482
+ is_me = (str(participant.id) == str(self.user.id))
483
+
484
+ data = {
485
+ 'type': 'NEW_CHAT_MESSAGE',
486
+ 'chat_id': str(chat.id),
487
+ 'prop_id': property_id,
488
+ 'chat_message': {
489
+ **serialized,
490
+ 'is_me': is_me,
491
+ 'is_ai': is_ai
492
+ }
493
+ }
494
+
495
+ if is_me:
496
+ # Send to sender
497
+ await self.send(text_data=json.dumps(data))
498
+ else:
499
+ # Send to other participant
500
+ await self.channel_layer.group_send(
501
+ f'ws_channel_layer_{participant.id}',
502
+ {'type': 'chat_message', 'message': data}
503
+ )
504
+
505
+ async def chat_message(self, event):
506
+ """Handle messages sent to user's channel"""
507
+ try:
508
+ await self.send(text_data=json.dumps(event['message']))
509
+ except Exception as e:
510
+ logger.error(f"Error sending chat message: {e}")
greeting_question.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ai_chatbot/questions_data.py
2
+
3
+ SYSTEM_QUESTIONS = [
4
+ {"question": "What is the load shedding situation in this area?", "order": 1},
5
+ {"question": "Is Sui gas available and functional?", "order": 2},
6
+ {"question": "Is water available 24/7 or are there specific timings?", "order": 3},
7
+ {"question": "Is there a dedicated parking space (Inside/Outside)?", "order": 4},
8
+ {"question": "Are there any hidden maintenance or security charges?", "order": 5},
9
+ {"question": "Is the security deposit refundable?", "order": 6},
10
+ {"question": "Is the internet (Fiber/DSL) pre-installed or available?", "order": 7},
11
+ ]
12
+
13
+ SYSTEM_GREETINGS = [
14
+ # --- ENGLISH GREETINGS ---
15
+ {"question": "Hello", "answer": "Hello! How can I help you with this property?", "language": "en", "priority": 10},
16
+ {"question": "Hi", "answer": "Hi there! Feel free to ask me any questions about this listing.", "language": "en", "priority": 10},
17
+ {"question": "Hey", "answer": "Hey! How can I assist you today?", "language": "en", "priority": 10},
18
+ {"question": "Good morning", "answer": "Good morning! How may I help you with this property?", "language": "en", "priority": 9},
19
+ {"question": "Good afternoon", "answer": "Good afternoon! What would you like to know about this property?", "language": "en", "priority": 9},
20
+ {"question": "Good evening", "answer": "Good evening! How can I assist you?", "language": "en", "priority": 9},
21
+ {"question": "How are you?", "answer": "I'm doing great, thank you! How can I help you with your property search?", "language": "en", "priority": 8},
22
+
23
+ # --- ROMAN URDU GREETINGS ---
24
+ {"question": "Assalam-o-Alaikum", "answer": "Wa Alaikum Assalam! Main ap ki kya madad kar sakta hoon?", "language": "ur", "priority": 10},
25
+ {"question": "Aoa", "answer": "Wa Alaikum Assalam! Kaisay madad kar sakta hoon?", "language": "ur", "priority": 10},
26
+ {"question": "Salam", "answer": "Wa Alaikum Assalam! Kya haal hain? Property ke baray mein kuch poochna hai?", "language": "ur", "priority": 10},
27
+ {"question": "Adaab", "answer": "Adaab! Kya main is property ki details share karoon?", "language": "ur", "priority": 9},
28
+ {"question": "Kya haal hai", "answer": "Allah ka shukar hai! Ap batayain, property ke baray mein kya janna chahte hain?", "language": "ur", "priority": 8},
29
+ {"question": "Kaisay ho", "answer": "Main bilkul theek! Ap property ke baray mein koi sawal pooch sakte hain.", "language": "ur", "priority": 8},
30
+ {"question": "Theek ho?", "answer": "Jee haan, shukriya! Main apki madad ke liye hazir hoon.", "language": "ur", "priority": 7},
31
+
32
+ # --- GRATITUDE & CLOSING (BILINGUAL) ---
33
+ {"question": "Thank you", "answer": "You're very welcome! Let me know if you need anything else.", "language": "en", "priority": 10},
34
+ {"question": "Thanks", "answer": "My pleasure! Feel free to ask more questions.", "language": "en", "priority": 10},
35
+ {"question": "Shukriya", "answer": "Apka khair maqdam hai! Koi aur baat jo ap poochna chahain?", "language": "ur", "priority": 10},
36
+ {"question": "Bohot shukriya", "answer": "Khushi hui apki madad karke! Allah hafiz.", "language": "ur", "priority": 10},
37
+ {"question": "JazakAllah", "answer": "Wa iyyakum! Main yahan apki mazeed madad ke liye hoon.", "language": "ur", "priority": 10},
38
+ {"question": "Bye", "answer": "Goodbye! Have a great day ahead.", "language": "en", "priority": 8},
39
+ {"question": "Allah hafiz", "answer": "Allah hafiz! Phir kabhi madad chahiye ho toh zaroor batayain.", "language": "ur", "priority": 8},
40
+
41
+ # --- COMMON MIXED PHRASES ---
42
+ {"question": "Hi, kya haal hai", "answer": "Hello! Main theek hoon. Property ke baray mein batayain, kya janna chahte hain?", "language": "both", "priority": 9},
43
+ {"question": "Salam, is it available?", "answer": "Wa Alaikum Assalam! Let me check the details for you. Our agent will confirm shortly.", "language": "both", "priority": 9},
44
+ ]
management/__init__.py ADDED
File without changes
management/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (157 Bytes). View file
 
management/commands/__init__.py ADDED
File without changes
management/commands/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (160 Bytes). View file
 
management/commands/__pycache__/setup_default_qa.cpython-312.pyc ADDED
Binary file (2.7 kB). View file
 
management/commands/__pycache__/setup_global_qa.cpython-312.pyc ADDED
Binary file (3.69 kB). View file
 
management/commands/__pycache__/setup_master_questions.cpython-312.pyc ADDED
Binary file (2.24 kB). View file
 
management/commands/__pycache__/test_model.cpython-312.pyc ADDED
Binary file (2.32 kB). View file
 
management/commands/__pycache__/websocket_test.cpython-312.pyc ADDED
Binary file (1.69 kB). View file
 
management/commands/setup_global_qa.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from django.core.management.base import BaseCommand
2
+ from ai_chatbot.models import GlobalQA
3
+ from ai_chatbot.services import EmbeddingService
4
+
5
+ class Command(BaseCommand):
6
+ help = 'Setup global greeting Q&A for all properties'
7
+
8
+ def handle(self, *args, **options):
9
+ service = EmbeddingService()
10
+
11
+ global_qa = [
12
+ # English Greetings
13
+ ("Hello", "Hello! How can I help you with this property?", "en", 10),
14
+ ("Hi", "Hi there! Feel free to ask me any questions about this property.", "en", 10),
15
+ ("Hey", "Hey! How can I assist you today?", "en", 10),
16
+ ("Good morning", "Good morning! How may I help you with this property?", "en", 9),
17
+ ("Good afternoon", "Good afternoon! What would you like to know about this property?", "en", 9),
18
+ ("Good evening", "Good evening! How can I assist you?", "en", 9),
19
+ ("How are you?", "I'm doing great! How can I help you with this property?", "en", 8),
20
+ ("Thanks", "You're welcome! Let me know if you have any other questions.", "en", 10),
21
+ ("Thank you", "My pleasure! Feel free to ask anything else.", "en", 10),
22
+ ("Thank you so much", "You're very welcome! Happy to help.", "en", 10),
23
+ ("Ok thanks", "Anytime! Let me know if you need more information.", "en", 9),
24
+ ("Bye", "Goodbye! Feel free to come back if you have more questions.", "en", 8),
25
+ ("Goodbye", "Take care! Have a great day.", "en", 8),
26
+
27
+ # Roman Urdu Greetings
28
+ ("Assalam-o-Alaikum", "Wa Alaikum Assalam! Main ap ki kya madad kar sakta hoon?", "ur", 10),
29
+ ("AoA", "Wa Alaikum Assalam! Kya sawal hai?", "ur", 10),
30
+ ("Salam", "Wa Alaikum Assalam! Kaisay madad kar sakta hoon?", "ur", 10),
31
+ ("Adaab", "Adaab! Kya poochna chahte ho property ke bare mein?", "ur", 9),
32
+ ("Kya haal hai", "Allah ka shukar hai! Ap property ke bare mein kya poochna chahte ho?", "ur", 8),
33
+ ("Theek ho", "Haan bilkul! Koi sawal poochain.", "ur", 8),
34
+ ("Shukriya", "Apka khair maqdam hai! Koi aur sawal?", "ur", 10),
35
+ ("Bohot shukriya", "Khushi hui madad karke! Aur poochain.", "ur", 10),
36
+ ("Allah hafiz", "Allah hafiz! Phir kabhi ayen.", "ur", 8),
37
+
38
+ # Mixed/Common
39
+ ("Hello, kya haal hai", "Wa Alaikum Assalam! Mein theek hoon. Ap property ke bare mein kya janna chahte ho?", "both", 9),
40
+ ("Hi, property available?", "Hello! Let me check the availability for you. Our agent will confirm shortly.", "both", 7),
41
+ ("Salam, price kya hai", "Wa Alaikum Assalam! Please allow our agent to share detailed pricing information.", "both", 7),
42
+ ]
43
+
44
+ count = 0
45
+ for question, answer, language, priority in global_qa:
46
+ embedding = service.generate_embedding(question)
47
+ obj, created = GlobalQA.objects.get_or_create(
48
+ question=question,
49
+ defaults={
50
+ 'answer': answer,
51
+ 'question_embedding': embedding,
52
+ 'language': language,
53
+ 'priority': priority,
54
+ 'is_active': True
55
+ }
56
+ )
57
+ if created:
58
+ count += 1
59
+ self.stdout.write(self.style.SUCCESS(f'✓ Added: {question[:40]}...'))
60
+ else:
61
+ # Update embedding if question exists
62
+ obj.question_embedding = embedding
63
+ obj.save()
64
+
65
+ self.stdout.write(self.style.SUCCESS(f'\n✅ Successfully added/updated {count} global Q&A pairs!'))
management/commands/setup_master_questions.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from django.core.management.base import BaseCommand
2
+ from ai_chatbot.models import MasterQuestion
3
+
4
+ class Command(BaseCommand):
5
+ help = 'Setup predefined master questions for property listings'
6
+
7
+ def handle(self, *args, **options):
8
+ questions = [
9
+ "What is the load shedding situation in this area?",
10
+ "Is there backup (UPS / Generator / Solar)?",
11
+ "Is Sui gas available?",
12
+ "Is water available 24/7?",
13
+ "Is the street paved or kacha?",
14
+ "Does water accumulate during rain?",
15
+ "Is parking inside or outside?",
16
+ "Is it safe for overnight parking?",
17
+ "How far is the nearest mosque?",
18
+ "Are schools, markets, and hospitals nearby?",
19
+ "Are there security guards?",
20
+ "Is CCTV installed?",
21
+ "Is the area considered safe at night?",
22
+ "Which internet services are available?",
23
+ "Any extra charges not mentioned? (maintenance, security, garbage)",
24
+ "Who pays utility bills?",
25
+ "Is the portion completely separate?",
26
+ "Is entrance shared with owner?",
27
+ "What is the monthly rent of the property?",
28
+ "What is the security deposit?",
29
+ "Is the rent negotiable?",
30
+ ]
31
+
32
+ count = 0
33
+ for order, question in enumerate(questions, start=1):
34
+ obj, created = MasterQuestion.objects.get_or_create(
35
+ question=question,
36
+ defaults={'order': order, 'is_active': True}
37
+ )
38
+ if created:
39
+ count += 1
40
+ self.stdout.write(self.style.SUCCESS(f'✓ Added: {question[:50]}...'))
41
+
42
+ self.stdout.write(self.style.SUCCESS(f'\n✅ Successfully added {count} master questions!'))
migrations/0001_initial.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Generated by Django 4.2.6 on 2026-04-29 07:00
2
+
3
+ from django.conf import settings
4
+ from django.db import migrations, models
5
+ import django.db.models.deletion
6
+ import pgvector.django.vector
7
+ import uuid
8
+
9
+
10
+ class Migration(migrations.Migration):
11
+
12
+ initial = True
13
+
14
+ dependencies = [
15
+ ('Property', '0063_alter_property_amenties'),
16
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
17
+ ]
18
+
19
+ operations = [
20
+ migrations.CreateModel(
21
+ name='AgencyAutoChatSetting',
22
+ fields=[
23
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
24
+ ('is_enabled', models.BooleanField(default=False)),
25
+ ('delay_seconds', models.IntegerField(default=30, help_text='Seconds to wait before AI replies')),
26
+ ('confidence_threshold', models.FloatField(default=0.6, help_text='Minimum similarity score (0-1)')),
27
+ ('created_at', models.DateTimeField(auto_now_add=True)),
28
+ ('updated_at', models.DateTimeField(auto_now=True)),
29
+ ],
30
+ ),
31
+ migrations.CreateModel(
32
+ name='GlobalQA',
33
+ fields=[
34
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
35
+ ('question', models.TextField(unique=True)),
36
+ ('answer', models.TextField()),
37
+ ('question_embedding', pgvector.django.vector.VectorField(dimensions=384)),
38
+ ('language', models.CharField(choices=[('en', 'English'), ('ur', 'Roman Urdu'), ('both', 'Both')], default='both', max_length=20)),
39
+ ('is_active', models.BooleanField(default=True)),
40
+ ('priority', models.IntegerField(default=0)),
41
+ ('created_at', models.DateTimeField(auto_now_add=True)),
42
+ ],
43
+ options={
44
+ 'ordering': ['-priority', 'question'],
45
+ },
46
+ ),
47
+ migrations.CreateModel(
48
+ name='MasterQuestion',
49
+ fields=[
50
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
51
+ ('question', models.TextField(unique=True)),
52
+ ('is_active', models.BooleanField(default=True)),
53
+ ('order', models.IntegerField(default=0)),
54
+ ('created_at', models.DateTimeField(auto_now_add=True)),
55
+ ],
56
+ options={
57
+ 'ordering': ['order'],
58
+ },
59
+ ),
60
+ migrations.CreateModel(
61
+ name='PropertyQA',
62
+ fields=[
63
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
64
+ ('answer', models.TextField()),
65
+ ('question_embedding', pgvector.django.vector.VectorField(dimensions=384)),
66
+ ('is_active', models.BooleanField(default=True)),
67
+ ('created_at', models.DateTimeField(auto_now_add=True)),
68
+ ('updated_at', models.DateTimeField(auto_now=True)),
69
+ ('agency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='property_qa_answers', to=settings.AUTH_USER_MODEL)),
70
+ ('master_question', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='ai_chatbot.masterquestion')),
71
+ ('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ai_qa', to='Property.property')),
72
+ ],
73
+ ),
74
+ migrations.CreateModel(
75
+ name='PropertyCustomQA',
76
+ fields=[
77
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
78
+ ('question', models.TextField()),
79
+ ('answer', models.TextField()),
80
+ ('question_embedding', pgvector.django.vector.VectorField(dimensions=384)),
81
+ ('order', models.IntegerField(default=0)),
82
+ ('is_active', models.BooleanField(default=True)),
83
+ ('created_at', models.DateTimeField(auto_now_add=True)),
84
+ ('updated_at', models.DateTimeField(auto_now=True)),
85
+ ('agency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='property_custom_qa', to=settings.AUTH_USER_MODEL)),
86
+ ('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='custom_qa', to='Property.property')),
87
+ ],
88
+ options={
89
+ 'ordering': ['order'],
90
+ },
91
+ ),
92
+ migrations.CreateModel(
93
+ name='PropertyAutoChatState',
94
+ fields=[
95
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
96
+ ('is_auto_chat_enabled', models.BooleanField(default=False)),
97
+ ('last_auto_reply_at', models.DateTimeField(blank=True, null=True)),
98
+ ('total_auto_replies', models.IntegerField(default=0)),
99
+ ('last_agency_reply_at', models.DateTimeField(blank=True, null=True)),
100
+ ('property', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='auto_chat_state', to='Property.property')),
101
+ ],
102
+ ),
103
+ migrations.AddIndex(
104
+ model_name='masterquestion',
105
+ index=models.Index(fields=['is_active', 'order'], name='ai_chatbot__is_acti_4e4a3d_idx'),
106
+ ),
107
+ migrations.AddIndex(
108
+ model_name='globalqa',
109
+ index=models.Index(fields=['is_active', '-priority'], name='ai_chatbot__is_acti_fa63b9_idx'),
110
+ ),
111
+ migrations.AddIndex(
112
+ model_name='globalqa',
113
+ index=models.Index(fields=['language', 'is_active'], name='ai_chatbot__languag_9d512a_idx'),
114
+ ),
115
+ migrations.AddField(
116
+ model_name='agencyautochatsetting',
117
+ name='agency',
118
+ field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='auto_chat_setting', to=settings.AUTH_USER_MODEL),
119
+ ),
120
+ migrations.AddIndex(
121
+ model_name='propertyqa',
122
+ index=models.Index(fields=['property', 'is_active'], name='ai_chatbot__propert_d93b25_idx'),
123
+ ),
124
+ migrations.AddIndex(
125
+ model_name='propertyqa',
126
+ index=models.Index(fields=['agency', 'is_active'], name='ai_chatbot__agency__41090d_idx'),
127
+ ),
128
+ migrations.AddIndex(
129
+ model_name='propertyqa',
130
+ index=models.Index(fields=['master_question', 'is_active'], name='ai_chatbot__master__144853_idx'),
131
+ ),
132
+ migrations.AlterUniqueTogether(
133
+ name='propertyqa',
134
+ unique_together={('property', 'master_question')},
135
+ ),
136
+ migrations.AddIndex(
137
+ model_name='propertycustomqa',
138
+ index=models.Index(fields=['property', 'is_active'], name='ai_chatbot__propert_e9a86b_idx'),
139
+ ),
140
+ migrations.AddIndex(
141
+ model_name='propertycustomqa',
142
+ index=models.Index(fields=['agency', 'is_active'], name='ai_chatbot__agency__fdf146_idx'),
143
+ ),
144
+ migrations.AddConstraint(
145
+ model_name='propertycustomqa',
146
+ constraint=models.UniqueConstraint(fields=('property', 'order'), name='unique_order_per_property'),
147
+ ),
148
+ migrations.AddIndex(
149
+ model_name='propertyautochatstate',
150
+ index=models.Index(fields=['is_auto_chat_enabled'], name='ai_chatbot__is_auto_1f624d_idx'),
151
+ ),
152
+ migrations.AddIndex(
153
+ model_name='propertyautochatstate',
154
+ index=models.Index(fields=['property', 'is_auto_chat_enabled'], name='ai_chatbot__propert_697127_idx'),
155
+ ),
156
+ migrations.AddIndex(
157
+ model_name='agencyautochatsetting',
158
+ index=models.Index(fields=['is_enabled'], name='ai_chatbot__is_enab_7f8ec6_idx'),
159
+ ),
160
+ ]
migrations/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # ai_chatbot migrations
migrations/__pycache__/0001_initial.cpython-312.pyc ADDED
Binary file (8.81 kB). View file
 
migrations/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (151 Bytes). View file
 
models.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ import builtins
3
+ from django.db import models
4
+ from pgvector.django import VectorField
5
+ from django.conf import settings
6
+
7
+
8
+ class GlobalQA(models.Model):
9
+ """Global Q&A for greetings - works across ALL properties and agencies"""
10
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
11
+ question = models.TextField(unique=True)
12
+ answer = models.TextField()
13
+ question_embedding = VectorField(dimensions=384)
14
+ language = models.CharField(max_length=20, choices=[
15
+ ('en', 'English'),
16
+ ('ur', 'Roman Urdu'),
17
+ ('both', 'Both')
18
+ ], default='both')
19
+ is_active = models.BooleanField(default=True)
20
+ priority = models.IntegerField(default=0)
21
+ created_at = models.DateTimeField(auto_now_add=True)
22
+
23
+ class Meta:
24
+ ordering = ['-priority', 'question']
25
+ indexes = [
26
+ models.Index(fields=['is_active', '-priority']),
27
+ models.Index(fields=['language', 'is_active']),
28
+ ]
29
+
30
+ def __str__(self):
31
+ return f"{self.question[:50]}"
32
+
33
+
34
+ class MasterQuestion(models.Model):
35
+ """Pre-defined questions created by admin that agencies must answer"""
36
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
37
+ question = models.TextField(unique=True)
38
+ is_active = models.BooleanField(default=True)
39
+ order = models.IntegerField(default=0)
40
+ created_at = models.DateTimeField(auto_now_add=True)
41
+
42
+ class Meta:
43
+ ordering = ['order']
44
+ indexes = [
45
+ models.Index(fields=['is_active', 'order']),
46
+ ]
47
+
48
+ def __str__(self):
49
+ return self.question[:100]
50
+
51
+
52
+ class PropertyQA(models.Model):
53
+ """Agency's answers to master questions for a specific property"""
54
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
55
+
56
+ agency = models.ForeignKey(
57
+ settings.AUTH_USER_MODEL,
58
+ on_delete=models.CASCADE,
59
+ related_name='property_qa_answers'
60
+ )
61
+ property = models.ForeignKey(
62
+ 'Property.Property',
63
+ on_delete=models.CASCADE,
64
+ related_name='ai_qa'
65
+ )
66
+ master_question = models.ForeignKey(
67
+ MasterQuestion,
68
+ on_delete=models.CASCADE,
69
+ null=True,
70
+ blank=True
71
+ )
72
+ answer = models.TextField()
73
+
74
+ # Embedding for similarity search
75
+ question_embedding = VectorField(dimensions=384)
76
+
77
+ is_active = models.BooleanField(default=True)
78
+ created_at = models.DateTimeField(auto_now_add=True)
79
+ updated_at = models.DateTimeField(auto_now=True) # FIX #5: Track updates
80
+
81
+ class Meta:
82
+ unique_together = ['property', 'master_question']
83
+ # FIX #2: Database indexes for performance
84
+ indexes = [
85
+ models.Index(fields=['property', 'is_active']),
86
+ models.Index(fields=['agency', 'is_active']),
87
+ models.Index(fields=['master_question', 'is_active']),
88
+ # Vector index will be added via migration
89
+ ]
90
+
91
+ @builtins.property
92
+ def question_text(self):
93
+ return self.master_question.question if self.master_question else None
94
+
95
+ def __str__(self):
96
+ return f"{self.property.title}: {self.question_text[:50] if self.question_text else 'No question'}"
97
+
98
+
99
+ class AgencyAutoChatSetting(models.Model):
100
+ """Auto-chat settings per agency"""
101
+ agency = models.OneToOneField(
102
+ settings.AUTH_USER_MODEL,
103
+ on_delete=models.CASCADE,
104
+ related_name='auto_chat_setting'
105
+ )
106
+ is_enabled = models.BooleanField(default=False)
107
+ delay_seconds = models.IntegerField(default=30, help_text="Seconds to wait before AI replies")
108
+ confidence_threshold = models.FloatField(default=0.6, help_text="Minimum similarity score (0-1)")
109
+ created_at = models.DateTimeField(auto_now_add=True)
110
+ updated_at = models.DateTimeField(auto_now=True)
111
+
112
+ class Meta:
113
+ indexes = [
114
+ models.Index(fields=['is_enabled']),
115
+ ]
116
+
117
+ def __str__(self):
118
+ return f"{self.agency.email}: {'ON' if self.is_enabled else 'OFF'}"
119
+
120
+
121
+ class PropertyAutoChatState(models.Model):
122
+ """Track auto-chat state per property"""
123
+ property = models.OneToOneField(
124
+ 'Property.Property',
125
+ on_delete=models.CASCADE,
126
+ related_name='auto_chat_state'
127
+ )
128
+ is_auto_chat_enabled = models.BooleanField(default=False)
129
+ last_auto_reply_at = models.DateTimeField(null=True, blank=True)
130
+ total_auto_replies = models.IntegerField(default=0)
131
+ last_agency_reply_at = models.DateTimeField(null=True, blank=True)
132
+
133
+ class Meta:
134
+ indexes = [
135
+ models.Index(fields=['is_auto_chat_enabled']),
136
+ models.Index(fields=['property', 'is_auto_chat_enabled']),
137
+ ]
138
+
139
+ def __str__(self):
140
+ return f"{self.property.title}: Auto Chat {'ON' if self.is_auto_chat_enabled else 'OFF'}"
141
+
142
+
143
+ class PropertyCustomQA(models.Model):
144
+ """
145
+ Custom Q&A for a specific property (max 5 per property)
146
+ Agency writes these when enabling auto-chat
147
+ """
148
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
149
+
150
+ # Relationships
151
+ agency = models.ForeignKey(
152
+ settings.AUTH_USER_MODEL,
153
+ on_delete=models.CASCADE,
154
+ related_name='property_custom_qa'
155
+ )
156
+ property = models.ForeignKey(
157
+ 'Property.Property',
158
+ on_delete=models.CASCADE,
159
+ related_name='custom_qa'
160
+ )
161
+
162
+ # The actual Q&A
163
+ question = models.TextField()
164
+ answer = models.TextField()
165
+
166
+ # For AI similarity search
167
+ question_embedding = VectorField(dimensions=384)
168
+
169
+ # Metadata
170
+ order = models.IntegerField(default=0) # 1-5 for display order
171
+ is_active = models.BooleanField(default=True)
172
+ created_at = models.DateTimeField(auto_now_add=True)
173
+ updated_at = models.DateTimeField(auto_now=True)
174
+
175
+ class Meta:
176
+ ordering = ['order']
177
+ # Ensure max 5 per property
178
+ constraints = [
179
+ models.UniqueConstraint(
180
+ fields=['property', 'order'],
181
+ name='unique_order_per_property'
182
+ )
183
+ ]
184
+ indexes = [
185
+ models.Index(fields=['property', 'is_active']),
186
+ models.Index(fields=['agency', 'is_active']),
187
+ ]
188
+
189
+ def __str__(self):
190
+ return f"{self.property.title}: Q{self.order} - {self.question[:50]}"
models/fine_tuned_bilingual_model_v2/1_Pooling/config.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "embedding_dimension": 384,
3
+ "pooling_mode": "mean",
4
+ "include_prompt": true
5
+ }
models/fine_tuned_bilingual_model_v2/README.md ADDED
@@ -0,0 +1,341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ tags:
3
+ - sentence-transformers
4
+ - sentence-similarity
5
+ - feature-extraction
6
+ - generated_from_trainer
7
+ - dataset_size:45
8
+ - loss:MultipleNegativesRankingLoss
9
+ base_model: embedingHF/bilingual-roman-urdu-embedder
10
+ widget:
11
+ - source_sentence: map location
12
+ sentences:
13
+ - is garage available
14
+ - how much is it
15
+ - bedrooms kitnay hain
16
+ - source_sentence: car stand hai
17
+ sentences:
18
+ - parking space
19
+ - parking facility
20
+ - precise location
21
+ - source_sentence: rooms kitnay hain
22
+ sentences:
23
+ - kitna bara
24
+ - number of rooms
25
+ - property rate
26
+ - source_sentence: yeh jaga kahan hai
27
+ sentences:
28
+ - where is this place
29
+ - bedrooms
30
+ - property size
31
+ - source_sentence: total area batao
32
+ sentences:
33
+ - location on map
34
+ - rate batao
35
+ - total area
36
+ pipeline_tag: sentence-similarity
37
+ library_name: sentence-transformers
38
+ ---
39
+
40
+ # SentenceTransformer based on embedingHF/bilingual-roman-urdu-embedder
41
+
42
+ This is a [sentence-transformers](https://www.SBERT.net) model finetuned from [embedingHF/bilingual-roman-urdu-embedder](https://huggingface.co/embedingHF/bilingual-roman-urdu-embedder). It maps sentences & paragraphs to a 384-dimensional dense vector space and can be used for retrieval.
43
+
44
+ ## Model Details
45
+
46
+ ### Model Description
47
+ - **Model Type:** Sentence Transformer
48
+ - **Base model:** [embedingHF/bilingual-roman-urdu-embedder](https://huggingface.co/embedingHF/bilingual-roman-urdu-embedder) <!-- at revision 733331b846073b4605769ad33ecf0931a3c35489 -->
49
+ - **Maximum Sequence Length:** 256 tokens
50
+ - **Output Dimensionality:** 384 dimensions
51
+ - **Similarity Function:** Cosine Similarity
52
+ - **Supported Modality:** Text
53
+ <!-- - **Training Dataset:** Unknown -->
54
+ <!-- - **Language:** Unknown -->
55
+ <!-- - **License:** Unknown -->
56
+
57
+ ### Model Sources
58
+
59
+ - **Documentation:** [Sentence Transformers Documentation](https://sbert.net)
60
+ - **Repository:** [Sentence Transformers on GitHub](https://github.com/huggingface/sentence-transformers)
61
+ - **Hugging Face:** [Sentence Transformers on Hugging Face](https://huggingface.co/models?library=sentence-transformers)
62
+
63
+ ### Full Model Architecture
64
+
65
+ ```
66
+ SentenceTransformer(
67
+ (0): Transformer({'transformer_task': 'feature-extraction', 'modality_config': {'text': {'method': 'forward', 'method_output_name': 'last_hidden_state'}}, 'module_output_name': 'token_embeddings', 'architecture': 'BertModel'})
68
+ (1): Pooling({'embedding_dimension': 384, 'pooling_mode': 'mean', 'include_prompt': True})
69
+ (2): Normalize({})
70
+ )
71
+ ```
72
+
73
+ ## Usage
74
+
75
+ ### Direct Usage (Sentence Transformers)
76
+
77
+ First install the Sentence Transformers library:
78
+
79
+ ```bash
80
+ pip install -U sentence-transformers
81
+ ```
82
+ Then you can load this model and run inference.
83
+ ```python
84
+ from sentence_transformers import SentenceTransformer
85
+
86
+ # Download from the 🤗 Hub
87
+ model = SentenceTransformer("sentence_transformers_model_id")
88
+ # Run inference
89
+ sentences = [
90
+ 'total area batao',
91
+ 'total area',
92
+ 'location on map',
93
+ ]
94
+ embeddings = model.encode(sentences)
95
+ print(embeddings.shape)
96
+ # [3, 384]
97
+
98
+ # Get the similarity scores for the embeddings
99
+ similarities = model.similarity(embeddings, embeddings)
100
+ print(similarities)
101
+ # tensor([[1.0000, 0.9458, 0.3963],
102
+ # [0.9458, 1.0000, 0.3714],
103
+ # [0.3963, 0.3714, 1.0000]])
104
+ ```
105
+ <!--
106
+ ### Direct Usage (Transformers)
107
+
108
+ <details><summary>Click to see the direct usage in Transformers</summary>
109
+
110
+ </details>
111
+ -->
112
+
113
+ <!--
114
+ ### Downstream Usage (Sentence Transformers)
115
+
116
+ You can finetune this model on your own dataset.
117
+
118
+ <details><summary>Click to expand</summary>
119
+
120
+ </details>
121
+ -->
122
+
123
+ <!--
124
+ ### Out-of-Scope Use
125
+
126
+ *List how the model may foreseeably be misused and address what users ought not to do with the model.*
127
+ -->
128
+
129
+ <!--
130
+ ## Bias, Risks and Limitations
131
+
132
+ *What are the known or foreseeable issues stemming from this model? You could also flag here known failure cases or weaknesses of the model.*
133
+ -->
134
+
135
+ <!--
136
+ ### Recommendations
137
+
138
+ *What are recommendations with respect to the foreseeable issues? For example, filtering explicit content.*
139
+ -->
140
+
141
+ ## Training Details
142
+
143
+ ### Training Dataset
144
+
145
+ #### Unnamed Dataset
146
+
147
+ * Size: 45 training samples
148
+ * Columns: <code>sentence_0</code>, <code>sentence_1</code>, and <code>label</code>
149
+ * Approximate statistics based on the first 45 samples:
150
+ | | sentence_0 | sentence_1 | label |
151
+ |:--------|:---------------------------------------------------------------------------------|:-------------------------------------------------------------------------------|:---------------------------------------------------------------|
152
+ | type | string | string | float |
153
+ | details | <ul><li>min: 3 tokens</li><li>mean: 5.69 tokens</li><li>max: 12 tokens</li></ul> | <ul><li>min: 3 tokens</li><li>mean: 5.4 tokens</li><li>max: 8 tokens</li></ul> | <ul><li>min: 0.0</li><li>mean: 0.67</li><li>max: 1.0</li></ul> |
154
+ * Samples:
155
+ | sentence_0 | sentence_1 | label |
156
+ |:------------------------------|:---------------------------------|:------------------|
157
+ | <code>near kahan hai</code> | <code>where is it located</code> | <code>1.0</code> |
158
+ | <code>total area batao</code> | <code>total area</code> | <code>1.0</code> |
159
+ | <code>size</code> | <code>area</code> | <code>0.84</code> |
160
+ * Loss: [<code>MultipleNegativesRankingLoss</code>](https://sbert.net/docs/package_reference/sentence_transformer/losses.html#multiplenegativesrankingloss) with these parameters:
161
+ ```json
162
+ {
163
+ "scale": 20.0,
164
+ "similarity_fct": "cos_sim",
165
+ "gather_across_devices": false,
166
+ "directions": [
167
+ "query_to_doc"
168
+ ],
169
+ "partition_mode": "joint",
170
+ "hardness_mode": null,
171
+ "hardness_strength": 0.0
172
+ }
173
+ ```
174
+
175
+ ### Training Hyperparameters
176
+ #### Non-Default Hyperparameters
177
+
178
+ - `num_train_epochs`: 20
179
+ - `multi_dataset_batch_sampler`: round_robin
180
+
181
+ #### All Hyperparameters
182
+ <details><summary>Click to expand</summary>
183
+
184
+ - `per_device_train_batch_size`: 8
185
+ - `num_train_epochs`: 20
186
+ - `max_steps`: -1
187
+ - `learning_rate`: 5e-05
188
+ - `lr_scheduler_type`: linear
189
+ - `lr_scheduler_kwargs`: None
190
+ - `warmup_steps`: 0
191
+ - `optim`: adamw_torch_fused
192
+ - `optim_args`: None
193
+ - `weight_decay`: 0.0
194
+ - `adam_beta1`: 0.9
195
+ - `adam_beta2`: 0.999
196
+ - `adam_epsilon`: 1e-08
197
+ - `optim_target_modules`: None
198
+ - `gradient_accumulation_steps`: 1
199
+ - `average_tokens_across_devices`: True
200
+ - `max_grad_norm`: 1
201
+ - `label_smoothing_factor`: 0.0
202
+ - `bf16`: False
203
+ - `fp16`: False
204
+ - `bf16_full_eval`: False
205
+ - `fp16_full_eval`: False
206
+ - `tf32`: None
207
+ - `gradient_checkpointing`: False
208
+ - `gradient_checkpointing_kwargs`: None
209
+ - `torch_compile`: False
210
+ - `torch_compile_backend`: None
211
+ - `torch_compile_mode`: None
212
+ - `use_liger_kernel`: False
213
+ - `liger_kernel_config`: None
214
+ - `use_cache`: False
215
+ - `neftune_noise_alpha`: None
216
+ - `torch_empty_cache_steps`: None
217
+ - `auto_find_batch_size`: False
218
+ - `log_on_each_node`: True
219
+ - `logging_nan_inf_filter`: True
220
+ - `include_num_input_tokens_seen`: no
221
+ - `log_level`: passive
222
+ - `log_level_replica`: warning
223
+ - `disable_tqdm`: False
224
+ - `project`: huggingface
225
+ - `trackio_space_id`: trackio
226
+ - `per_device_eval_batch_size`: 8
227
+ - `prediction_loss_only`: True
228
+ - `eval_on_start`: False
229
+ - `eval_do_concat_batches`: True
230
+ - `eval_use_gather_object`: False
231
+ - `eval_accumulation_steps`: None
232
+ - `include_for_metrics`: []
233
+ - `batch_eval_metrics`: False
234
+ - `save_only_model`: False
235
+ - `save_on_each_node`: False
236
+ - `enable_jit_checkpoint`: False
237
+ - `push_to_hub`: False
238
+ - `hub_private_repo`: None
239
+ - `hub_model_id`: None
240
+ - `hub_strategy`: every_save
241
+ - `hub_always_push`: False
242
+ - `hub_revision`: None
243
+ - `load_best_model_at_end`: False
244
+ - `ignore_data_skip`: False
245
+ - `restore_callback_states_from_checkpoint`: False
246
+ - `full_determinism`: False
247
+ - `seed`: 42
248
+ - `data_seed`: None
249
+ - `use_cpu`: False
250
+ - `accelerator_config`: {'split_batches': False, 'dispatch_batches': None, 'even_batches': True, 'use_seedable_sampler': True, 'non_blocking': False, 'gradient_accumulation_kwargs': None}
251
+ - `parallelism_config`: None
252
+ - `dataloader_drop_last`: False
253
+ - `dataloader_num_workers`: 0
254
+ - `dataloader_pin_memory`: True
255
+ - `dataloader_persistent_workers`: False
256
+ - `dataloader_prefetch_factor`: None
257
+ - `remove_unused_columns`: True
258
+ - `label_names`: None
259
+ - `train_sampling_strategy`: random
260
+ - `length_column_name`: length
261
+ - `ddp_find_unused_parameters`: None
262
+ - `ddp_bucket_cap_mb`: None
263
+ - `ddp_broadcast_buffers`: False
264
+ - `ddp_backend`: None
265
+ - `ddp_timeout`: 1800
266
+ - `fsdp`: []
267
+ - `fsdp_config`: {'min_num_params': 0, 'xla': False, 'xla_fsdp_v2': False, 'xla_fsdp_grad_ckpt': False}
268
+ - `deepspeed`: None
269
+ - `debug`: []
270
+ - `skip_memory_metrics`: True
271
+ - `do_predict`: False
272
+ - `resume_from_checkpoint`: None
273
+ - `warmup_ratio`: None
274
+ - `local_rank`: -1
275
+ - `prompts`: None
276
+ - `batch_sampler`: batch_sampler
277
+ - `multi_dataset_batch_sampler`: round_robin
278
+ - `router_mapping`: {}
279
+ - `learning_rate_mapping`: {}
280
+
281
+ </details>
282
+
283
+ ### Training Time
284
+ - **Training**: 39.7 seconds
285
+
286
+ ### Framework Versions
287
+ - Python: 3.12.3
288
+ - Sentence Transformers: 5.4.1
289
+ - Transformers: 5.5.4
290
+ - PyTorch: 2.11.0+cpu
291
+ - Accelerate: 1.13.0
292
+ - Datasets: 4.8.4
293
+ - Tokenizers: 0.22.2
294
+
295
+ ## Citation
296
+
297
+ ### BibTeX
298
+
299
+ #### Sentence Transformers
300
+ ```bibtex
301
+ @inproceedings{reimers-2019-sentence-bert,
302
+ title = "Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks",
303
+ author = "Reimers, Nils and Gurevych, Iryna",
304
+ booktitle = "Proceedings of the 2019 Conference on Empirical Methods in Natural Language Processing",
305
+ month = "11",
306
+ year = "2019",
307
+ publisher = "Association for Computational Linguistics",
308
+ url = "https://arxiv.org/abs/1908.10084",
309
+ }
310
+ ```
311
+
312
+ #### MultipleNegativesRankingLoss
313
+ ```bibtex
314
+ @misc{oord2019representationlearningcontrastivepredictive,
315
+ title={Representation Learning with Contrastive Predictive Coding},
316
+ author={Aaron van den Oord and Yazhe Li and Oriol Vinyals},
317
+ year={2019},
318
+ eprint={1807.03748},
319
+ archivePrefix={arXiv},
320
+ primaryClass={cs.LG},
321
+ url={https://arxiv.org/abs/1807.03748},
322
+ }
323
+ ```
324
+
325
+ <!--
326
+ ## Glossary
327
+
328
+ *Clearly define terms in order to be accessible across audiences.*
329
+ -->
330
+
331
+ <!--
332
+ ## Model Card Authors
333
+
334
+ *Lists the people who create the model card, providing recognition and accountability for the detailed work that goes into its construction.*
335
+ -->
336
+
337
+ <!--
338
+ ## Model Card Contact
339
+
340
+ *Provides a way for people who have updates to the Model Card, suggestions, or questions, to contact the Model Card authors.*
341
+ -->
models/fine_tuned_bilingual_model_v2/config.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_cross_attention": false,
3
+ "architectures": [
4
+ "BertModel"
5
+ ],
6
+ "attention_probs_dropout_prob": 0.1,
7
+ "bos_token_id": null,
8
+ "classifier_dropout": null,
9
+ "dtype": "float32",
10
+ "eos_token_id": null,
11
+ "gradient_checkpointing": false,
12
+ "hidden_act": "gelu",
13
+ "hidden_dropout_prob": 0.1,
14
+ "hidden_size": 384,
15
+ "initializer_range": 0.02,
16
+ "intermediate_size": 1536,
17
+ "is_decoder": false,
18
+ "layer_norm_eps": 1e-12,
19
+ "max_position_embeddings": 512,
20
+ "model_type": "bert",
21
+ "num_attention_heads": 12,
22
+ "num_hidden_layers": 6,
23
+ "pad_token_id": 0,
24
+ "position_embedding_type": "absolute",
25
+ "tie_word_embeddings": true,
26
+ "transformers_version": "5.5.4",
27
+ "type_vocab_size": 2,
28
+ "use_cache": true,
29
+ "vocab_size": 30522
30
+ }
models/fine_tuned_bilingual_model_v2/config_sentence_transformers.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "__version__": {
3
+ "pytorch": "2.11.0+cpu",
4
+ "sentence_transformers": "5.4.1",
5
+ "transformers": "5.5.4"
6
+ },
7
+ "default_prompt_name": null,
8
+ "model_type": "SentenceTransformer",
9
+ "prompts": {
10
+ "document": "",
11
+ "query": ""
12
+ },
13
+ "similarity_fn_name": "cosine"
14
+ }
models/fine_tuned_bilingual_model_v2/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:abbc47f63434959c6907d6dc24d818fc2ea9e2a77f2be398c0b2453784f2d951
3
+ size 90864176
models/fine_tuned_bilingual_model_v2/modules.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "idx": 0,
4
+ "name": "0",
5
+ "path": "",
6
+ "type": "sentence_transformers.base.modules.transformer.Transformer"
7
+ },
8
+ {
9
+ "idx": 1,
10
+ "name": "1",
11
+ "path": "1_Pooling",
12
+ "type": "sentence_transformers.sentence_transformer.modules.pooling.Pooling"
13
+ },
14
+ {
15
+ "idx": 2,
16
+ "name": "2",
17
+ "path": "2_Normalize",
18
+ "type": "sentence_transformers.sentence_transformer.modules.normalize.Normalize"
19
+ }
20
+ ]
models/fine_tuned_bilingual_model_v2/sentence_bert_config.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "transformer_task": "feature-extraction",
3
+ "modality_config": {
4
+ "text": {
5
+ "method": "forward",
6
+ "method_output_name": "last_hidden_state"
7
+ }
8
+ },
9
+ "module_output_name": "token_embeddings"
10
+ }
models/fine_tuned_bilingual_model_v2/tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
models/fine_tuned_bilingual_model_v2/tokenizer_config.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "backend": "tokenizers",
3
+ "cls_token": "[CLS]",
4
+ "do_basic_tokenize": true,
5
+ "do_lower_case": true,
6
+ "is_local": false,
7
+ "mask_token": "[MASK]",
8
+ "max_length": 128,
9
+ "model_max_length": 256,
10
+ "never_split": null,
11
+ "pad_to_multiple_of": null,
12
+ "pad_token": "[PAD]",
13
+ "pad_token_type_id": 0,
14
+ "padding_side": "right",
15
+ "sep_token": "[SEP]",
16
+ "stride": 0,
17
+ "strip_accents": null,
18
+ "tokenize_chinese_chars": true,
19
+ "tokenizer_class": "BertTokenizer",
20
+ "truncation_side": "right",
21
+ "truncation_strategy": "longest_first",
22
+ "unk_token": "[UNK]"
23
+ }
serializers.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from rest_framework import serializers
2
+ from .models import PropertyCustomQA
3
+
4
+ class PropertyCustomQASerializer(serializers.ModelSerializer):
5
+ class Meta:
6
+ model = PropertyCustomQA
7
+ fields = ['id', 'question', 'answer', 'order', 'is_active']
8
+ read_only_fields = ['id', 'created_at', 'updated_at']
9
+
10
+ def validate_question(self, value):
11
+ if len(value.strip()) < 5:
12
+ raise serializers.ValidationError("Question must be at least 5 characters")
13
+ return value.strip()
14
+
15
+ def validate_answer(self, value):
16
+ if len(value.strip()) < 5:
17
+ raise serializers.ValidationError("Answer must be at least 5 characters")
18
+ return value.strip()
19
+
20
+
21
+ class EnableAutoChatSerializer(serializers.Serializer):
22
+ """Serializer for enabling auto-chat with custom questions"""
23
+ property_id = serializers.UUIDField()
24
+ enable = serializers.BooleanField(default=True)
25
+ custom_questions = serializers.ListField(
26
+ child=PropertyCustomQASerializer(),
27
+ required=False,
28
+ max_length=5, # Max 5 questions
29
+ min_length=0
30
+ )
31
+
32
+ def validate_custom_questions(self, value):
33
+ if len(value) > 5:
34
+ raise serializers.ValidationError("Maximum 5 custom questions allowed")
35
+ return value
36
+
37
+ def validate(self, data):
38
+ # Check if all master questions are answered first
39
+ from .models import MasterQuestion, PropertyQA
40
+
41
+ property_id = data['property_id']
42
+ total_master = MasterQuestion.objects.filter(is_active=True).count()
43
+ answered_master = PropertyQA.objects.filter(
44
+ property_id=property_id,
45
+ master_question__is_active=True
46
+ ).count()
47
+
48
+ if answered_master < total_master:
49
+ raise serializers.ValidationError(
50
+ f"Please answer all {total_master} master questions first. "
51
+ f"Currently answered: {answered_master}/{total_master}"
52
+ )
53
+
54
+ return data
services.py ADDED
@@ -0,0 +1,488 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import threading
3
+ from typing import List, Dict, Optional, Any
4
+
5
+ import numpy as np
6
+ from sentence_transformers import SentenceTransformer
7
+ from django.conf import settings
8
+ from django.utils import timezone
9
+ from pgvector.django import CosineDistance
10
+
11
+ from .models import GlobalQA, PropertyCustomQA, PropertyQA, AgencyAutoChatSetting, PropertyAutoChatState
12
+ from django.db.models.signals import post_save
13
+ from django.dispatch import receiver
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class EmbeddingService:
18
+ """
19
+ FIX #7: Thread-safe singleton with per-thread model instances
20
+ Prevents memory leak and handles Django's multi-threaded environment
21
+ """
22
+ _instances: Dict[int, 'EmbeddingService'] = {}
23
+ _lock = threading.Lock()
24
+ _model_path = None
25
+
26
+ def __new__(cls):
27
+ """Thread-aware singleton: different instance per thread"""
28
+ thread_id = threading.get_ident()
29
+
30
+ if thread_id not in cls._instances:
31
+ with cls._lock:
32
+ if thread_id not in cls._instances:
33
+ instance = super().__new__(cls)
34
+ instance._loaded = False
35
+ cls._instances[thread_id] = instance
36
+
37
+ instance = cls._instances[thread_id]
38
+ if not instance._loaded:
39
+ instance._load_model()
40
+ instance._loaded = True
41
+
42
+ return instance
43
+
44
+ def __init__(self):
45
+ """Initialize but don't load model until needed"""
46
+ if not hasattr(self, '_loaded'):
47
+ self._loaded = False
48
+
49
+ def _load_model(self):
50
+ """Load the sentence transformer model"""
51
+ try:
52
+ model_path = getattr(settings, 'AI_MODEL_PATH', 'all-MiniLM-L6-v2')
53
+ self._model = SentenceTransformer(model_path)
54
+ logger.info(f"✅ AI Model loaded in thread {threading.get_ident()} from {model_path}")
55
+ except Exception as e:
56
+ logger.error(f"❌ Failed to load model: {e}")
57
+ raise
58
+
59
+ @classmethod
60
+ def cleanup_thread(cls):
61
+ """Clean up model for current thread (call when thread exits)"""
62
+ thread_id = threading.get_ident()
63
+ if thread_id in cls._instances:
64
+ del cls._instances[thread_id]
65
+ logger.info(f"🧹 Cleaned up AI model for thread {thread_id}")
66
+
67
+ def generate_embedding(self, text: str) -> List[float]:
68
+ """Generate embedding vector for text"""
69
+ if not hasattr(self, '_model') or self._model is None:
70
+ self._load_model()
71
+
72
+ embedding = self._model.encode(text)
73
+ return embedding.tolist()
74
+
75
+ def find_best_answer_for_property(self, question: str, property_id: str,
76
+ threshold: float = 0.6) -> Dict[str, Any]:
77
+ """Find best matching answer for this specific property"""
78
+ question_embedding = self.generate_embedding(question)
79
+
80
+ # Check Global Q&A first (greetings)
81
+ best_global = GlobalQA.objects.filter(is_active=True).annotate(
82
+ distance=CosineDistance('question_embedding', question_embedding)
83
+ ).order_by('distance').first()
84
+
85
+ if best_global:
86
+ confidence = 1 - best_global.distance
87
+ if confidence > threshold:
88
+ return {
89
+ 'answer': best_global.answer,
90
+ 'confidence': confidence,
91
+ 'matched_question': best_global.question,
92
+ 'type': 'global'
93
+ }
94
+
95
+ # Check Property Q&A
96
+ if property_id:
97
+ best_prop = PropertyQA.objects.filter(
98
+ property_id=property_id,
99
+ is_active=True
100
+ ).annotate(
101
+ distance=CosineDistance('question_embedding', question_embedding)
102
+ ).select_related('master_question').order_by('distance').first()
103
+
104
+ if best_prop:
105
+ confidence = 1 - best_prop.distance
106
+ if confidence > threshold:
107
+ return {
108
+ 'answer': best_prop.answer,
109
+ 'confidence': confidence,
110
+ 'matched_question': best_prop.master_question.question,
111
+ 'type': 'property'
112
+ }
113
+
114
+ return {
115
+ 'answer': "Our agent will respond shortly. Thank you for your patience!",
116
+ 'confidence': 0.0,
117
+ 'matched_question': None,
118
+ 'type': 'fallback'
119
+ }
120
+
121
+
122
+ # ai_chatbot/services.py - Update find_best_answer_global_first
123
+
124
+ def find_best_answer_global_first(self, question: str, property_id: str = None,
125
+ threshold: float = 0.6) -> Dict[str, Any]:
126
+ """
127
+ Priority: 1. Property Data (dynamic) | 2. Global Greetings | 3. Property Master QA | 4. Property Custom QA | 5. Fallback
128
+ """
129
+
130
+ # ✅ NEW: Check property data first (highest priority)
131
+ if property_id:
132
+ property_data_answer = self.get_property_info_answer(question, property_id)
133
+ if property_data_answer:
134
+ return property_data_answer
135
+
136
+ question_embedding = self.generate_embedding(question)
137
+
138
+ # 1. Global Greetings
139
+ best_global = GlobalQA.objects.filter(is_active=True).annotate(
140
+ distance=CosineDistance('question_embedding', question_embedding)
141
+ ).order_by('distance').first()
142
+
143
+ if best_global:
144
+ global_conf = 1 - best_global.distance
145
+ if global_conf > 0.92:
146
+ return {
147
+ 'answer': best_global.answer,
148
+ 'confidence': global_conf,
149
+ 'matched_question': best_global.question,
150
+ 'type': 'global'
151
+ }
152
+
153
+ # 2. Property Master Q&A
154
+ if property_id:
155
+ best_prop = PropertyQA.objects.filter(
156
+ property_id=property_id,
157
+ is_active=True
158
+ ).annotate(
159
+ distance=CosineDistance('question_embedding', question_embedding)
160
+ ).select_related('master_question').order_by('distance').first()
161
+
162
+ if best_prop:
163
+ prop_conf = 1 - best_prop.distance
164
+ if prop_conf > threshold:
165
+ return {
166
+ 'answer': best_prop.answer,
167
+ 'confidence': prop_conf,
168
+ 'matched_question': best_prop.master_question.question,
169
+ 'type': 'property_master'
170
+ }
171
+
172
+ # 3. Property Custom Q&A
173
+ if property_id:
174
+ best_custom = PropertyCustomQA.objects.filter(
175
+ property_id=property_id,
176
+ is_active=True
177
+ ).annotate(
178
+ distance=CosineDistance('question_embedding', question_embedding)
179
+ ).order_by('distance').first()
180
+
181
+ if best_custom:
182
+ custom_conf = 1 - best_custom.distance
183
+ if custom_conf > threshold:
184
+ return {
185
+ 'answer': best_custom.answer,
186
+ 'confidence': custom_conf,
187
+ 'matched_question': best_custom.question,
188
+ 'type': 'property_custom'
189
+ }
190
+
191
+ # 4. Fallback
192
+ return {
193
+ 'answer': "Our agent will respond shortly. Thank you for your patience!",
194
+ 'confidence': 0.0,
195
+ 'matched_question': None,
196
+ 'type': 'fallback'
197
+ }
198
+
199
+
200
+ def get_property_info_answer(self, question: str, property_id: str) -> Dict[str, Any]:
201
+ """Get answer from actual property data"""
202
+ from Property.models import Property
203
+
204
+ try:
205
+ # ✅ Remove 'area' from select_related - it's not a foreign key
206
+ property_obj = Property.objects.select_related('city').get(id=property_id)
207
+ except Property.DoesNotExist:
208
+ return None
209
+
210
+ question_lower = question.lower()
211
+
212
+ # City/Location questions
213
+ if any(word in question_lower for word in ['city', 'location', 'which city', 'kahan', 'city name', 'shahar']):
214
+ city_name = getattr(property_obj, 'city', None)
215
+ if city_name:
216
+ if hasattr(city_name, 'city'):
217
+ city = city_name.city
218
+ else:
219
+ city = str(city_name)
220
+ else:
221
+ city = "Karachi"
222
+ return {
223
+ 'answer': f"This property is located in {city}, Pakistan.",
224
+ 'confidence': 0.98,
225
+ 'type': 'property_data'
226
+ }
227
+
228
+ # Area/Sector questions - handle area as a property field (not foreign key)
229
+ elif any(word in question_lower for word in ['area', 'sector', 'phase', 'block', 'location detail', 'area name']):
230
+ # ✅ 'area' is likely a direct field, not a foreign key
231
+ area = getattr(property_obj, 'area', None)
232
+ if area:
233
+ # If area is a string or has __str__ method
234
+ area_name = str(area)
235
+ else:
236
+ area_name = "the main area"
237
+ return {
238
+ 'answer': f"This property is in {area_name} area.",
239
+ 'confidence': 0.98,
240
+ 'type': 'property_data'
241
+ }
242
+
243
+ # Address questions
244
+ elif any(word in question_lower for word in ['address', 'exact location', 'full address', 'pata', 'complete address']):
245
+ address = getattr(property_obj, 'address', None)
246
+ if address:
247
+ return {
248
+ 'answer': f"Full address: {address}",
249
+ 'confidence': 0.98,
250
+ 'type': 'property_data'
251
+ }
252
+
253
+ # Price/Rent questions
254
+ elif any(word in question_lower for word in ['price', 'rent', 'cost', 'kitna', 'rate', 'price kya hai', 'rent kya hai']):
255
+ price = getattr(property_obj, 'price', None)
256
+ if price:
257
+ return {
258
+ 'answer': f"The price for this property is PKR {price:,}.",
259
+ 'confidence': 0.98,
260
+ 'type': 'property_data'
261
+ }
262
+
263
+ # Bedrooms
264
+ elif any(word in question_lower for word in ['bedroom', 'bed', 'rooms', 'kitne kamray', 'bed count']):
265
+ beds = getattr(property_obj, 'beds', None)
266
+ if beds:
267
+ return {
268
+ 'answer': f"This property has {beds} bedroom(s).",
269
+ 'confidence': 0.98,
270
+ 'type': 'property_data'
271
+ }
272
+
273
+ # Bathrooms
274
+ elif any(word in question_lower for word in ['bathroom', 'bath', 'washroom', 'bathrooms', 'bath count']):
275
+ baths = getattr(property_obj, 'baths', None)
276
+ if baths:
277
+ return {
278
+ 'answer': f"This property has {baths} bathroom(s).",
279
+ 'confidence': 0.98,
280
+ 'type': 'property_data'
281
+ }
282
+
283
+ # Property size
284
+ elif any(word in question_lower for word in ['size', 'area size', 'total area', 'square feet', 'sqft', 'marle']):
285
+ area_size = getattr(property_obj, 'area_unit', None) or getattr(property_obj, 'total_area', None) or getattr(property_obj, 'land_area', None)
286
+ if area_size:
287
+ return {
288
+ 'answer': f"The total area of this property is {area_size} sq ft.",
289
+ 'confidence': 0.98,
290
+ 'type': 'property_data'
291
+ }
292
+
293
+ # Property type / Category
294
+ elif any(word in question_lower for word in ['type', 'property type', 'kind', 'category']):
295
+ category = getattr(property_obj, 'category', None)
296
+ if category:
297
+ if hasattr(category, 'category'):
298
+ category_name = category.category
299
+ else:
300
+ category_name = str(category)
301
+ return {
302
+ 'answer': f"This is a {category_name} property.",
303
+ 'confidence': 0.98,
304
+ 'type': 'property_data'
305
+ }
306
+
307
+ elif any(word in question_lower for word in ['detail', 'property information', 'property detail kiya hai', 'description', 'description kiya hai']):
308
+ desc = getattr(property_obj, 'desc', None)
309
+ if desc:
310
+ if hasattr(desc, 'desc'):
311
+ description_name = desc.desc
312
+ else:
313
+ description_name = str(desc)
314
+ return {
315
+ 'answer': f"iss property ki detail hai: {description_name}.",
316
+ 'confidence': 0.98,
317
+ 'type': 'property_data'
318
+ }
319
+
320
+ return None
321
+
322
+ def _cosine_similarity(self, embedding1: List[float], embedding2: List[float]) -> float:
323
+ """Calculate cosine similarity between two embeddings"""
324
+ vec1 = np.array(embedding1)
325
+ vec2 = np.array(embedding2)
326
+ dot = np.dot(vec1, vec2)
327
+ norm1 = np.linalg.norm(vec1)
328
+ norm2 = np.linalg.norm(vec2)
329
+ return float(dot / (norm1 * norm2)) if norm1 and norm2 else 0.0
330
+
331
+
332
+ class AutoChatService:
333
+ """Orchestration service for auto-chat functionality"""
334
+
335
+ def __init__(self):
336
+ self.embedding_service = EmbeddingService()
337
+
338
+ def should_auto_reply(self, chat_id: str, property_id: str,
339
+ last_agency_reply_at=None, last_client_message_at=None) -> bool:
340
+ """Check if auto-reply should trigger"""
341
+ from Chat.models import Chat
342
+
343
+ try:
344
+ # Get property auto-chat state
345
+ property_state = PropertyAutoChatState.objects.get(property_id=property_id)
346
+
347
+ if not property_state.is_auto_chat_enabled:
348
+ return False
349
+
350
+ # Get agency settings
351
+ property_obj = property_state.property
352
+ agency_setting = AgencyAutoChatSetting.objects.get(agency=property_obj.user)
353
+
354
+ if not agency_setting.is_enabled:
355
+ return False
356
+
357
+ if not last_client_message_at:
358
+ return False
359
+
360
+ # If agency replied after last client message, don't auto-reply
361
+ if last_agency_reply_at and last_agency_reply_at > last_client_message_at:
362
+ return False
363
+
364
+ # Check if configured delay passed
365
+ time_diff = (timezone.now() - last_client_message_at).total_seconds()
366
+ return time_diff >= agency_setting.delay_seconds
367
+
368
+ except (PropertyAutoChatState.DoesNotExist, AgencyAutoChatSetting.DoesNotExist):
369
+ return False
370
+ except Exception as e:
371
+ logger.error(f"Error checking auto-reply: {e}")
372
+ return False
373
+
374
+ def generate_auto_reply(self, client_message: str, property_id: str,
375
+ chat_id: str) -> Optional[Dict[str, Any]]:
376
+ """Generate auto-reply with proper thresholds"""
377
+ try:
378
+ from Property.models import Property
379
+
380
+ property_obj = Property.objects.get(id=property_id)
381
+ agency_setting = AgencyAutoChatSetting.objects.get(agency=property_obj.user)
382
+
383
+ # Use agency's configured threshold
384
+ result = self.embedding_service.find_best_answer_global_first(
385
+ client_message,
386
+ property_id,
387
+ agency_setting.confidence_threshold
388
+ )
389
+
390
+ logger.info(f"🤖 Auto-reply | Type: {result['type']} | Confidence: {result['confidence']:.2f}")
391
+
392
+ return {
393
+ 'answer': result['answer'],
394
+ 'confidence': result['confidence'],
395
+ 'matched_question': result.get('matched_question'),
396
+ 'is_auto_reply': True,
397
+ 'reply_type': result['type'],
398
+ 'is_fallback': result['type'] == 'fallback'
399
+ }
400
+
401
+ except Property.DoesNotExist:
402
+ logger.error(f"Property {property_id} not found")
403
+ return None
404
+ except AgencyAutoChatSetting.DoesNotExist:
405
+ logger.warning(f"No agency settings for property {property_id}, using defaults")
406
+ # Fallback to defaults
407
+ result = self.embedding_service.find_best_answer_global_first(
408
+ client_message, property_id, 0.6
409
+ )
410
+ return {
411
+ 'answer': result['answer'],
412
+ 'confidence': result['confidence'],
413
+ 'matched_question': result.get('matched_question'),
414
+ 'is_auto_reply': True,
415
+ 'reply_type': result['type'],
416
+ 'is_fallback': result['type'] == 'fallback'
417
+ }
418
+ except Exception as e:
419
+ logger.error(f"Auto-reply error: {e}", exc_info=True)
420
+ return None
421
+
422
+ def sync_property_listing_to_ai(self, property_id: str) -> bool:
423
+ """
424
+ Sync property data to AI Q&A
425
+ FIX #5: Don't delete existing, update in place
426
+ """
427
+ from .models import MasterQuestion, PropertyQA
428
+ from Property.models import Property
429
+
430
+ try:
431
+ property_obj = Property.objects.get(id=property_id)
432
+ agency = property_obj.user
433
+
434
+ # Mapping of listing fields to Master Questions
435
+ data_map = {
436
+ "What is the price or rent of this property?": f"The demand for this property is PKR {property_obj if hasattr(property_obj, 'price') else 'contact agent'}.",
437
+ "What is the location and area of this property?": f"This property is located in {getattr(property_obj, 'area', 'the area')}, {getattr(property_obj, 'city', 'the city')}.",
438
+ "How many bedrooms and bathrooms does it have?": f"It is a {getattr(property_obj, 'beds', 'N/A')} bedroom property with {getattr(property_obj, 'baths', 'N/A')} bathrooms.",
439
+ "What is the total size or area of the property?": f"The total size is {getattr(property_obj, 'area', 'N/A')} {getattr(property_obj, 'area_unit', 'sq ft')}.",
440
+ "What type of property is it?": f"This is a {getattr(property_obj, 'category', 'property')}.",
441
+ "How many kitchens and TV lounges does it have?": f"It has {getattr(property_obj, 'kitchen', 'N/A')} kitchens and {getattr(property_obj, 'tv_launch', 'N/A')} TV lounges.",
442
+ "What is contact number of the agent?": f"You can contact the agent at {getattr(property_obj, 'agent_two_phone_number', 'the provided number')}.",
443
+ "Information About this property": f"{getattr(property_obj, 'desc', 'Contact agent for details')}",
444
+
445
+ }
446
+
447
+ updated_count = 0
448
+ created_count = 0
449
+
450
+ for q_text, answer_text in data_map.items():
451
+ master_q = MasterQuestion.objects.filter(question=q_text, is_active=True).first()
452
+
453
+ if master_q:
454
+ embedding = self.embedding_service.generate_embedding(q_text)
455
+
456
+ # FIX #5: Update or create without deleting
457
+ obj, created = PropertyQA.objects.update_or_create(
458
+ property=property_obj,
459
+ master_question=master_q,
460
+ defaults={
461
+ 'agency': agency,
462
+ 'answer': answer_text,
463
+ 'question_embedding': embedding,
464
+ 'is_active': True
465
+ }
466
+ )
467
+
468
+ if created:
469
+ created_count += 1
470
+ else:
471
+ updated_count += 1
472
+
473
+ logger.info(f"✅ Sync complete for Property {property_id} | Created: {created_count} | Updated: {updated_count}")
474
+ return True
475
+
476
+ except Exception as e:
477
+ logger.error(f"❌ Sync error for property {property_id}: {e}", exc_info=True)
478
+ return False
479
+
480
+ @receiver(post_save, sender='Property.Property')
481
+ def auto_sync_ai_on_listing_save(sender, instance, created, **kwargs):
482
+ """Jab bhi property save ho, AI data sync karo"""
483
+ try:
484
+ from ai_chatbot.services import AutoChatService
485
+ service = AutoChatService()
486
+ service.sync_property_listing_to_ai(instance.id)
487
+ except Exception as e:
488
+ logger.error(f"Signal Error: {e}")
tests.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from django.test import TestCase
2
+
3
+ # Create your tests here.
urls.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from django.urls import path
2
+ from . import views
3
+
4
+ urlpatterns = [
5
+ # Admin Global Q&A CRUD
6
+ path('admin/global-qa/', views.admin_global_qa),
7
+ path('admin/global-qa/<uuid:qa_id>/', views.admin_global_qa),
8
+ path('admin/global-qa/bulk/', views.admin_global_qa_bulk),
9
+
10
+ # Admin CRUD (Single)
11
+ path('admin/master-questions/', views.admin_master_questions),
12
+ path('admin/master-questions/<uuid:question_id>/', views.admin_master_questions),
13
+
14
+ # Admin Bulk Create
15
+ path('admin/master-questions/bulk/', views.admin_master_questions_bulk),
16
+
17
+ # Agency endpoints
18
+ path('master-questions/', views.get_master_questions),
19
+ path('property-qa/save-bulk/', views.save_property_qa_bulk),
20
+ path('property-qa/status/<uuid:property_id>/', views.get_property_qa_status),
21
+ path('auto-chat/toggle/<uuid:property_id>/', views.toggle_auto_chat),
22
+ path('auto-chat/enable-with-questions/', views.enable_auto_chat_with_questions),
23
+ path('custom-questions/<uuid:property_id>/', views.get_custom_questions),
24
+
25
+ # Webhook for Chat app
26
+ path('webhook/auto-reply/', views.webhook_auto_reply),
27
+ ]
views.py ADDED
@@ -0,0 +1,813 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import secrets
3
+ from django.conf import settings
4
+ from django.core.exceptions import ValidationError
5
+ from rest_framework import status
6
+ from rest_framework.decorators import api_view, authentication_classes, permission_classes
7
+ from rest_framework.permissions import IsAuthenticated, AllowAny
8
+ from rest_framework.response import Response
9
+ from rest_framework_simplejwt.authentication import JWTAuthentication
10
+
11
+ from ai_chatbot.serializers import EnableAutoChatSerializer, PropertyCustomQASerializer
12
+
13
+ from .models import MasterQuestion, PropertyCustomQA, PropertyQA, AgencyAutoChatSetting, PropertyAutoChatState, GlobalQA
14
+ from .services import EmbeddingService
15
+
16
+ logger = logging.getLogger(__name__)
17
+ embedding_service = EmbeddingService()
18
+
19
+
20
+ # FIX #8: Add serializer validation
21
+ from rest_framework import serializers
22
+
23
+ class QAAnswerSerializer(serializers.Serializer):
24
+ master_question_id = serializers.UUIDField(required=True)
25
+ answer = serializers.CharField(max_length=5000, required=True, allow_blank=False)
26
+
27
+ class BulkQASerializer(serializers.Serializer):
28
+ property_id = serializers.UUIDField(required=True)
29
+ answers = QAAnswerSerializer(many=True, required=True)
30
+
31
+
32
+ # FIX #3: Webhook authentication
33
+ class WebhookSecretAuthentication:
34
+ """Custom authentication for webhook endpoint"""
35
+
36
+ def authenticate(self, request):
37
+ secret = request.headers.get('X-Webhook-Secret')
38
+ expected_secret = getattr(settings, 'AI_WEBHOOK_SECRET', None)
39
+
40
+ if not expected_secret:
41
+ logger.warning("AI_WEBHOOK_SECRET not set in settings")
42
+ return None
43
+
44
+ if secret and secrets.compare_digest(secret, expected_secret):
45
+ return (None, None) # Valid
46
+ return None
47
+
48
+ def authenticate_header(self, request):
49
+ return 'X-Webhook-Secret'
50
+
51
+
52
+ @api_view(['GET'])
53
+ @authentication_classes([JWTAuthentication])
54
+ @permission_classes([IsAuthenticated])
55
+ def get_master_questions(request):
56
+ """Get all master questions for agency to answer"""
57
+ questions = MasterQuestion.objects.filter(is_active=True).order_by('order')
58
+ data = [{'id': str(q.id), 'question': q.question, 'order': q.order} for q in questions]
59
+ return Response({'questions': data})
60
+
61
+
62
+ @api_view(['GET'])
63
+ @authentication_classes([JWTAuthentication])
64
+ @permission_classes([IsAuthenticated])
65
+ def get_property_qa_status(request, property_id):
66
+ """Get Q&A status for a property"""
67
+ user = request.user
68
+
69
+ master_questions = MasterQuestion.objects.filter(is_active=True).order_by('order')
70
+
71
+ # Get existing answers
72
+ existing_qa = {
73
+ str(qa.master_question_id): {
74
+ 'answer': qa.answer,
75
+ 'qa_id': str(qa.id),
76
+ 'updated_at': qa.updated_at
77
+ }
78
+ for qa in PropertyQA.objects.filter(
79
+ property_id=property_id,
80
+ agency=user
81
+ ).select_related('master_question')
82
+ }
83
+
84
+ questions_data = []
85
+ for mq in master_questions:
86
+ mq_id = str(mq.id)
87
+ questions_data.append({
88
+ 'id': mq_id,
89
+ 'question': mq.question,
90
+ 'order': mq.order,
91
+ 'is_answered': mq_id in existing_qa,
92
+ 'answer': existing_qa[mq_id]['answer'] if mq_id in existing_qa else None,
93
+ 'updated_at': existing_qa[mq_id]['updated_at'] if mq_id in existing_qa else None,
94
+ })
95
+
96
+ # Get settings
97
+ try:
98
+ auto_chat_setting = AgencyAutoChatSetting.objects.get(agency=user)
99
+ auto_chat_enabled = auto_chat_setting.is_enabled
100
+ delay_seconds = auto_chat_setting.delay_seconds
101
+ confidence_threshold = auto_chat_setting.confidence_threshold
102
+ except AgencyAutoChatSetting.DoesNotExist:
103
+ auto_chat_enabled = False
104
+ delay_seconds = 30
105
+ confidence_threshold = 0.6
106
+
107
+ try:
108
+ property_auto_state = PropertyAutoChatState.objects.get(property_id=property_id)
109
+ property_auto_enabled = property_auto_state.is_auto_chat_enabled
110
+ except PropertyAutoChatState.DoesNotExist:
111
+ property_auto_enabled = False
112
+
113
+ answered_count = sum(1 for q in questions_data if q['is_answered'])
114
+ total_questions = len(questions_data)
115
+
116
+ return Response({
117
+ 'property_id': property_id,
118
+ 'master_questions': questions_data,
119
+ 'stats': {
120
+ 'total_questions': total_questions,
121
+ 'answered_count': answered_count,
122
+ 'completion_percentage': round((answered_count / total_questions) * 100, 1) if total_questions > 0 else 0,
123
+ 'remaining_count': total_questions - answered_count
124
+ },
125
+ 'auto_chat_settings': {
126
+ 'is_enabled': auto_chat_enabled,
127
+ 'property_auto_enabled': property_auto_enabled,
128
+ 'delay_seconds': delay_seconds,
129
+ 'confidence_threshold': confidence_threshold
130
+ }
131
+ })
132
+
133
+
134
+ @api_view(['POST'])
135
+ @authentication_classes([JWTAuthentication])
136
+ @permission_classes([IsAuthenticated])
137
+ def save_property_qa_bulk(request):
138
+ """
139
+ FIX #5 & #9: Optimized bulk save with proper auto-enable logic
140
+ """
141
+ user = request.user
142
+
143
+ # Check role
144
+ if not hasattr(user, 'role') or getattr(user.role, 'role_type', None) != 'agency':
145
+ return Response({'error': 'Only agencies can add Q&A'}, status=status.HTTP_403_FORBIDDEN)
146
+
147
+ # FIX #8: Validate input
148
+ serializer = BulkQASerializer(data=request.data)
149
+ if not serializer.is_valid():
150
+ return Response({'error': 'Invalid data', 'details': serializer.errors},
151
+ status=status.HTTP_400_BAD_REQUEST)
152
+
153
+ validated_data = serializer.validated_data
154
+ property_id = validated_data['property_id']
155
+ answers = validated_data['answers']
156
+
157
+ if not answers:
158
+ return Response({'error': 'No answers provided'}, status=status.HTTP_400_BAD_REQUEST)
159
+
160
+ # FIX #5: Get existing answers to minimize database operations
161
+ existing_qa = {
162
+ str(qa.master_question_id): qa
163
+ for qa in PropertyQA.objects.filter(
164
+ property_id=property_id,
165
+ agency=user
166
+ ).select_related('master_question')
167
+ }
168
+
169
+ # Track which master questions were answered
170
+ answered_master_ids = set()
171
+ created_count = 0
172
+ updated_count = 0
173
+ errors = []
174
+
175
+ for item in answers:
176
+ master_q_id = str(item['master_question_id'])
177
+ answer = item['answer'].strip()
178
+
179
+ if not answer:
180
+ continue
181
+
182
+ try:
183
+ master_q = MasterQuestion.objects.get(id=master_q_id, is_active=True)
184
+ answered_master_ids.add(master_q_id)
185
+
186
+ # Generate embedding
187
+ embedding = embedding_service.generate_embedding(master_q.question)
188
+
189
+ # Update or create
190
+ if master_q_id in existing_qa:
191
+ # Update existing
192
+ qa = existing_qa[master_q_id]
193
+ qa.answer = answer
194
+ qa.question_embedding = embedding
195
+ qa.save(update_fields=['answer', 'question_embedding', 'updated_at'])
196
+ updated_count += 1
197
+ else:
198
+ # Create new
199
+ PropertyQA.objects.create(
200
+ agency=user,
201
+ property_id=property_id,
202
+ master_question=master_q,
203
+ answer=answer,
204
+ question_embedding=embedding
205
+ )
206
+ created_count += 1
207
+
208
+ except MasterQuestion.DoesNotExist:
209
+ errors.append(f'Master question not found: {master_q_id}')
210
+ except Exception as e:
211
+ errors.append(f'Error for question {master_q_id}: {str(e)}')
212
+
213
+ # Delete answers that were removed (not in current submission)
214
+ to_delete_ids = set(existing_qa.keys()) - answered_master_ids
215
+ if to_delete_ids:
216
+ deleted_count = PropertyQA.objects.filter(
217
+ property_id=property_id,
218
+ master_question_id__in=to_delete_ids
219
+ ).delete()[0]
220
+ logger.info(f"Deleted {deleted_count} removed answers for property {property_id}")
221
+
222
+ # FIX #9: Auto-enable only when ALL ACTIVE master questions are answered
223
+ total_active_questions = MasterQuestion.objects.filter(is_active=True).count()
224
+ total_answered = PropertyQA.objects.filter(
225
+ property_id=property_id,
226
+ master_question__is_active=True
227
+ ).count()
228
+
229
+ auto_chat_enabled = False
230
+ if total_answered >= total_active_questions and total_active_questions > 0:
231
+ property_state, created = PropertyAutoChatState.objects.update_or_create(
232
+ property_id=property_id,
233
+ defaults={'is_auto_chat_enabled': True}
234
+ )
235
+ auto_chat_enabled = True
236
+ logger.info(f"✅ Auto-chat enabled for property {property_id} ({total_answered}/{total_active_questions} questions answered)")
237
+
238
+ return Response({
239
+ 'message': f'Saved answers for property',
240
+ 'created': created_count,
241
+ 'updated': updated_count,
242
+ 'total': created_count + updated_count,
243
+ 'auto_chat_enabled': auto_chat_enabled,
244
+ 'completion': {
245
+ 'answered': total_answered,
246
+ 'total': total_active_questions,
247
+ 'percentage': round((total_answered / total_active_questions) * 100, 1) if total_active_questions > 0 else 0
248
+ },
249
+ 'errors': errors if errors else None
250
+ }, status=status.HTTP_200_OK if (created_count + updated_count) > 0 else status.HTTP_400_BAD_REQUEST)
251
+
252
+
253
+ @api_view(['POST'])
254
+ @authentication_classes([JWTAuthentication])
255
+ @permission_classes([IsAuthenticated])
256
+ def toggle_auto_chat(request, property_id):
257
+ """Enable/disable auto-chat for a specific property"""
258
+ user = request.user
259
+
260
+ if not hasattr(user, 'role') or getattr(user.role, 'role_type', None) != 'agency':
261
+ return Response({'error': 'Only agencies can configure auto-chat'}, status=status.HTTP_403_FORBIDDEN)
262
+
263
+ is_enabled = request.data.get('is_enabled', False)
264
+
265
+ # Validate before enabling
266
+ if is_enabled:
267
+ total_required = MasterQuestion.objects.filter(is_active=True).count()
268
+ total_answered = PropertyQA.objects.filter(
269
+ property_id=property_id,
270
+ agency=user,
271
+ master_question__is_active=True
272
+ ).count()
273
+
274
+ if total_answered < total_required:
275
+ return Response({
276
+ 'error': f'Please answer all {total_required} active questions first. Currently answered: {total_answered}/{total_required}'
277
+ }, status=status.HTTP_400_BAD_REQUEST)
278
+
279
+ # Update property auto-chat state
280
+ property_state, created = PropertyAutoChatState.objects.get_or_create(
281
+ property_id=property_id,
282
+ defaults={'is_auto_chat_enabled': is_enabled}
283
+ )
284
+
285
+ if not created:
286
+ property_state.is_auto_chat_enabled = is_enabled
287
+ property_state.save()
288
+
289
+ # Also update agency-wide setting
290
+ agency_setting, _ = AgencyAutoChatSetting.objects.get_or_create(
291
+ agency=user,
292
+ defaults={
293
+ 'is_enabled': is_enabled,
294
+ 'delay_seconds': 30,
295
+ 'confidence_threshold': 0.6
296
+ }
297
+ )
298
+
299
+ if not agency_setting.is_enabled and is_enabled:
300
+ agency_setting.is_enabled = True
301
+ agency_setting.save()
302
+
303
+ return Response({
304
+ 'property_id': property_id,
305
+ 'is_enabled': property_state.is_auto_chat_enabled,
306
+ 'message': f'Auto-chat {"enabled" if is_enabled else "disabled"} for this property'
307
+ })
308
+
309
+
310
+ @api_view(['POST'])
311
+ @authentication_classes([WebhookSecretAuthentication]) # FIX #3: Secure webhook
312
+ @permission_classes([AllowAny])
313
+ def webhook_auto_reply(request):
314
+ """
315
+ FIX #3: Secured webhook endpoint for Chat app to trigger auto-reply
316
+ """
317
+ from .services import AutoChatService
318
+
319
+ # Validate required fields
320
+ required_fields = ['chat_id', 'property_id', 'message']
321
+ for field in required_fields:
322
+ if field not in request.data:
323
+ return Response({'error': f'Missing required field: {field}'},
324
+ status=status.HTTP_400_BAD_REQUEST)
325
+
326
+ data = request.data
327
+ chat_id = data.get('chat_id')
328
+ property_id = data.get('property_id')
329
+ client_message = data.get('message')
330
+ last_agency_reply_at = data.get('last_agency_reply_at')
331
+ last_client_message_at = data.get('last_client_message_at')
332
+
333
+ auto_chat_service = AutoChatService()
334
+
335
+ # Check if auto-reply should trigger
336
+ if auto_chat_service.should_auto_reply(chat_id, property_id, last_agency_reply_at, last_client_message_at):
337
+ reply = auto_chat_service.generate_auto_reply(client_message, property_id, chat_id)
338
+ if reply:
339
+ return Response(reply)
340
+ return Response({'should_auto_reply': False, 'error': 'Failed to generate reply'})
341
+
342
+ return Response({'should_auto_reply': False})
343
+
344
+
345
+ @api_view(['POST'])
346
+ @authentication_classes([JWTAuthentication])
347
+ @permission_classes([IsAuthenticated])
348
+ def enable_auto_chat_with_questions(request):
349
+ """
350
+ Enable auto-chat for a property and add custom questions (max 5)
351
+ """
352
+ user = request.user
353
+
354
+ # Check if agency
355
+ if not hasattr(user, 'role') or getattr(user.role, 'role_type', None) != 'agency':
356
+ return Response({'error': 'Only agencies can enable auto-chat'},
357
+ status=status.HTTP_403_FORBIDDEN)
358
+
359
+ # Validate input
360
+ serializer = EnableAutoChatSerializer(data=request.data)
361
+ if not serializer.is_valid():
362
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
363
+
364
+ validated_data = serializer.validated_data
365
+ property_id = validated_data['property_id']
366
+ enable = validated_data['enable']
367
+ custom_questions = validated_data.get('custom_questions', [])
368
+
369
+ # Verify property belongs to this agency
370
+ from Property.models import Property
371
+ try:
372
+ property_obj = Property.objects.get(id=property_id, user=user)
373
+ except Property.DoesNotExist:
374
+ return Response({'error': 'Property not found or does not belong to you'},
375
+ status=status.HTTP_404_NOT_FOUND)
376
+
377
+ # Delete existing custom questions for this property
378
+ PropertyCustomQA.objects.filter(property_id=property_id).delete()
379
+
380
+ # Add new custom questions with embeddings
381
+ created_questions = []
382
+ for idx, q_data in enumerate(custom_questions):
383
+ # Generate embedding for the question
384
+ embedding = embedding_service.generate_embedding(q_data['question'])
385
+
386
+ custom_qa = PropertyCustomQA.objects.create(
387
+ agency=user,
388
+ property=property_obj,
389
+ question=q_data['question'],
390
+ answer=q_data['answer'],
391
+ question_embedding=embedding,
392
+ order=idx + 1, # 1, 2, 3, 4, 5
393
+ is_active=True
394
+ )
395
+ created_questions.append({
396
+ 'order': custom_qa.order,
397
+ 'question': custom_qa.question,
398
+ 'answer': custom_qa.answer
399
+ })
400
+
401
+ # Enable or disable auto-chat
402
+ property_state, created = PropertyAutoChatState.objects.update_or_create(
403
+ property_id=property_id,
404
+ defaults={'is_auto_chat_enabled': enable}
405
+ )
406
+
407
+ # Also update agency settings
408
+ agency_setting, _ = AgencyAutoChatSetting.objects.get_or_create(
409
+ agency=user,
410
+ defaults={
411
+ 'is_enabled': enable,
412
+ 'delay_seconds': 30,
413
+ 'confidence_threshold': 0.7
414
+ }
415
+ )
416
+ if enable and not agency_setting.is_enabled:
417
+ agency_setting.is_enabled = True
418
+ agency_setting.save()
419
+
420
+ return Response({
421
+ 'success': True,
422
+ 'property_id': str(property_id),
423
+ 'auto_chat_enabled': property_state.is_auto_chat_enabled,
424
+ 'custom_questions_added': len(created_questions),
425
+ 'custom_questions': created_questions,
426
+ 'message': f'Auto-chat {"enabled" if enable else "disabled"} with {len(created_questions)} custom questions'
427
+ }, status=status.HTTP_200_OK)
428
+
429
+
430
+ @api_view(['GET'])
431
+ @authentication_classes([JWTAuthentication])
432
+ @permission_classes([IsAuthenticated])
433
+ def get_custom_questions(request, property_id):
434
+ """
435
+ Get custom questions for a property (for editing)
436
+ """
437
+ user = request.user
438
+
439
+ # Verify property belongs to agency
440
+ from Property.models import Property
441
+ try:
442
+ property_obj = Property.objects.get(id=property_id, user=user)
443
+ except Property.DoesNotExist:
444
+ return Response({'error': 'Property not found'}, status=status.HTTP_404_NOT_FOUND)
445
+
446
+ custom_questions = PropertyCustomQA.objects.filter(
447
+ property_id=property_id,
448
+ is_active=True
449
+ ).order_by('order')
450
+
451
+ serializer = PropertyCustomQASerializer(custom_questions, many=True)
452
+
453
+ # Get auto-chat status
454
+ auto_chat_state = PropertyAutoChatState.objects.filter(property_id=property_id).first()
455
+
456
+ return Response({
457
+ 'property_id': str(property_id),
458
+ 'auto_chat_enabled': auto_chat_state.is_auto_chat_enabled if auto_chat_state else False,
459
+ 'custom_questions': serializer.data,
460
+ 'max_allowed': 5,
461
+ 'remaining_slots': 5 - len(serializer.data)
462
+ })
463
+
464
+ import uuid
465
+ from rest_framework.decorators import api_view, permission_classes
466
+ from rest_framework.permissions import IsAdminUser
467
+
468
+
469
+ @api_view(['GET', 'POST', 'PUT', 'DELETE'])
470
+ @permission_classes([IsAdminUser])
471
+ def admin_master_questions(request, question_id=None):
472
+ """
473
+ Admin CRUD for master questions
474
+ """
475
+
476
+ # ==================== GET ====================
477
+ if request.method == 'GET':
478
+ if question_id:
479
+ try:
480
+ question = MasterQuestion.objects.get(id=question_id)
481
+ data = {
482
+ 'id': str(question.id),
483
+ 'question': question.question,
484
+ 'order': question.order,
485
+ 'is_active': question.is_active,
486
+ 'created_at': question.created_at
487
+ }
488
+ return Response(data)
489
+ except MasterQuestion.DoesNotExist:
490
+ return Response({'error': 'Question not found'}, status=404)
491
+ else:
492
+ questions = MasterQuestion.objects.all().order_by('order')
493
+ data = [{
494
+ 'id': str(q.id),
495
+ 'question': q.question,
496
+ 'order': q.order,
497
+ 'is_active': q.is_active
498
+ } for q in questions]
499
+ return Response({
500
+ 'count': len(data),
501
+ 'questions': data
502
+ })
503
+
504
+ # ==================== CREATE (Single) ====================
505
+ elif request.method == 'POST':
506
+ question_text = request.data.get('question')
507
+ order = request.data.get('order')
508
+ is_active = request.data.get('is_active', True)
509
+
510
+ if not question_text:
511
+ return Response({'error': 'Question text is required'}, status=400)
512
+
513
+ # Check duplicate
514
+ if MasterQuestion.objects.filter(question=question_text).exists():
515
+ return Response({'error': 'Question already exists'}, status=400)
516
+
517
+ # Auto assign order if not provided
518
+ if order is None:
519
+ order = MasterQuestion.objects.count() + 1
520
+
521
+ question = MasterQuestion.objects.create(
522
+ id=uuid.uuid4(),
523
+ question=question_text,
524
+ order=order,
525
+ is_active=is_active
526
+ )
527
+
528
+ return Response({
529
+ 'id': str(question.id),
530
+ 'question': question.question,
531
+ 'order': question.order,
532
+ 'is_active': question.is_active,
533
+ 'message': 'Master question created successfully'
534
+ }, status=201)
535
+
536
+ # ==================== UPDATE ====================
537
+ elif request.method == 'PUT':
538
+ if not question_id:
539
+ return Response({'error': 'Question ID required'}, status=400)
540
+
541
+ try:
542
+ question = MasterQuestion.objects.get(id=question_id)
543
+ except MasterQuestion.DoesNotExist:
544
+ return Response({'error': 'Question not found'}, status=404)
545
+
546
+ question_text = request.data.get('question', question.question)
547
+ order = request.data.get('order', question.order)
548
+ is_active = request.data.get('is_active', question.is_active)
549
+
550
+ question.question = question_text
551
+ question.order = order
552
+ question.is_active = is_active
553
+ question.save()
554
+
555
+ return Response({
556
+ 'id': str(question.id),
557
+ 'question': question.question,
558
+ 'order': question.order,
559
+ 'is_active': question.is_active,
560
+ 'message': 'Master question updated successfully'
561
+ })
562
+
563
+ # ==================== DELETE ====================
564
+ elif request.method == 'DELETE':
565
+ if not question_id:
566
+ return Response({'error': 'Question ID required'}, status=400)
567
+
568
+ try:
569
+ question = MasterQuestion.objects.get(id=question_id)
570
+ question.delete()
571
+ return Response({'message': 'Master question deleted successfully'})
572
+ except MasterQuestion.DoesNotExist:
573
+ return Response({'error': 'Question not found'}, status=404)
574
+
575
+
576
+ # ==================== NEW: BULK CREATE ====================
577
+ @api_view(['POST'])
578
+ @permission_classes([IsAdminUser])
579
+ def admin_master_questions_bulk(request):
580
+ """
581
+ Admin can create multiple master questions at once
582
+ """
583
+ questions_data = request.data.get('questions', [])
584
+
585
+ if not questions_data:
586
+ return Response({'error': 'Questions list is required'}, status=400)
587
+
588
+ created = []
589
+ errors = []
590
+
591
+ for idx, q_data in enumerate(questions_data):
592
+ question_text = q_data.get('question')
593
+ order = q_data.get('order', idx + 1)
594
+ is_active = q_data.get('is_active', True)
595
+
596
+ if not question_text:
597
+ errors.append({'index': idx, 'error': 'Question text required'})
598
+ continue
599
+
600
+ # Skip duplicates
601
+ if MasterQuestion.objects.filter(question=question_text).exists():
602
+ errors.append({'index': idx, 'question': question_text, 'error': 'Already exists'})
603
+ continue
604
+
605
+ try:
606
+ question = MasterQuestion.objects.create(
607
+ id=uuid.uuid4(),
608
+ question=question_text,
609
+ order=order,
610
+ is_active=is_active
611
+ )
612
+ created.append({
613
+ 'id': str(question.id),
614
+ 'question': question.question,
615
+ 'order': question.order
616
+ })
617
+ except Exception as e:
618
+ errors.append({'index': idx, 'question': question_text, 'error': str(e)})
619
+
620
+ return Response({
621
+ 'message': f'Created {len(created)} questions',
622
+ 'created': created,
623
+ 'errors': errors if errors else None
624
+ }, status=201 if created else 400)
625
+
626
+
627
+ # ==================== GET ACTIVE QUESTIONS FOR AGENCY ====================
628
+ @api_view(['GET'])
629
+ @authentication_classes([JWTAuthentication])
630
+ @permission_classes([IsAuthenticated])
631
+ def get_master_questions(request):
632
+ """
633
+ Get all active master questions for agency to answer
634
+ """
635
+ questions = MasterQuestion.objects.filter(is_active=True).order_by('order')
636
+ data = [{
637
+ 'id': str(q.id),
638
+ 'question': q.question,
639
+ 'order': q.order
640
+ } for q in questions]
641
+ return Response({
642
+ 'total': len(data),
643
+ 'questions': data
644
+ })
645
+
646
+ # ==================== GLOBAL Q&A CRUD ====================
647
+
648
+ @api_view(['GET', 'POST', 'PUT', 'DELETE'])
649
+ @permission_classes([IsAdminUser])
650
+ def admin_global_qa(request, qa_id=None):
651
+ """
652
+ Admin CRUD for Global Q&A (applies to ALL properties)
653
+ """
654
+
655
+ # GET all or single
656
+ if request.method == 'GET':
657
+ if qa_id:
658
+ try:
659
+ qa = GlobalQA.objects.get(id=qa_id)
660
+ data = {
661
+ 'id': str(qa.id),
662
+ 'question': qa.question,
663
+ 'answer': qa.answer,
664
+ 'language': qa.language,
665
+ 'priority': qa.priority,
666
+ 'is_active': qa.is_active
667
+ }
668
+ return Response(data)
669
+ except GlobalQA.DoesNotExist:
670
+ return Response({'error': 'Not found'}, status=404)
671
+ else:
672
+ qa_list = GlobalQA.objects.all().order_by('-priority', 'question')
673
+ data = [{
674
+ 'id': str(q.id),
675
+ 'question': q.question,
676
+ 'answer': q.answer[:100],
677
+ 'language': q.language,
678
+ 'priority': q.priority,
679
+ 'is_active': q.is_active
680
+ } for q in qa_list]
681
+ return Response({'total': len(data), 'global_qa': data})
682
+
683
+ # CREATE single
684
+ elif request.method == 'POST':
685
+ question = request.data.get('question')
686
+ answer = request.data.get('answer')
687
+ language = request.data.get('language', 'both')
688
+ priority = request.data.get('priority', 0)
689
+ is_active = request.data.get('is_active', True)
690
+
691
+ if not question or not answer:
692
+ return Response({'error': 'Question and answer required'}, status=400)
693
+
694
+ if GlobalQA.objects.filter(question=question).exists():
695
+ return Response({'error': 'Question already exists'}, status=400)
696
+
697
+ # Generate embedding
698
+ embedding = embedding_service.generate_embedding(question)
699
+
700
+ qa = GlobalQA.objects.create(
701
+ id=uuid.uuid4(),
702
+ question=question,
703
+ answer=answer,
704
+ question_embedding=embedding,
705
+ language=language,
706
+ priority=priority,
707
+ is_active=is_active
708
+ )
709
+
710
+ return Response({
711
+ 'id': str(qa.id),
712
+ 'question': qa.question,
713
+ 'answer': qa.answer,
714
+ 'message': 'Global Q&A created successfully'
715
+ }, status=201)
716
+
717
+ # UPDATE
718
+ elif request.method == 'PUT':
719
+ if not qa_id:
720
+ return Response({'error': 'ID required'}, status=400)
721
+
722
+ try:
723
+ qa = GlobalQA.objects.get(id=qa_id)
724
+ except GlobalQA.DoesNotExist:
725
+ return Response({'error': 'Not found'}, status=404)
726
+
727
+ question = request.data.get('question', qa.question)
728
+ answer = request.data.get('answer', qa.answer)
729
+ language = request.data.get('language', qa.language)
730
+ priority = request.data.get('priority', qa.priority)
731
+ is_active = request.data.get('is_active', qa.is_active)
732
+
733
+ # Update embedding if question changed
734
+ if question != qa.question:
735
+ embedding = embedding_service.generate_embedding(question)
736
+ qa.question_embedding = embedding
737
+
738
+ qa.question = question
739
+ qa.answer = answer
740
+ qa.language = language
741
+ qa.priority = priority
742
+ qa.is_active = is_active
743
+ qa.save()
744
+
745
+ return Response({'message': 'Global Q&A updated successfully'})
746
+
747
+ # DELETE
748
+ elif request.method == 'DELETE':
749
+ if not qa_id:
750
+ return Response({'error': 'ID required'}, status=400)
751
+
752
+ try:
753
+ qa = GlobalQA.objects.get(id=qa_id)
754
+ qa.delete()
755
+ return Response({'message': 'Global Q&A deleted successfully'})
756
+ except GlobalQA.DoesNotExist:
757
+ return Response({'error': 'Not found'}, status=404)
758
+
759
+
760
+ # ==================== BULK CREATE GLOBAL Q&A ====================
761
+
762
+ @api_view(['POST'])
763
+ @permission_classes([IsAdminUser])
764
+ def admin_global_qa_bulk(request):
765
+ """
766
+ Admin can create multiple Global Q&A at once
767
+ """
768
+ items = request.data.get('items', [])
769
+
770
+ if not items:
771
+ return Response({'error': 'Items list required'}, status=400)
772
+
773
+ created = []
774
+ errors = []
775
+
776
+ for idx, item in enumerate(items):
777
+ question = item.get('question')
778
+ answer = item.get('answer')
779
+ language = item.get('language', 'both')
780
+ priority = item.get('priority', 0)
781
+
782
+ if not question or not answer:
783
+ errors.append({'index': idx, 'error': 'Question and answer required'})
784
+ continue
785
+
786
+ if GlobalQA.objects.filter(question=question).exists():
787
+ errors.append({'index': idx, 'question': question, 'error': 'Already exists'})
788
+ continue
789
+
790
+ try:
791
+ embedding = embedding_service.generate_embedding(question)
792
+ qa = GlobalQA.objects.create(
793
+ id=uuid.uuid4(),
794
+ question=question,
795
+ answer=answer,
796
+ question_embedding=embedding,
797
+ language=language,
798
+ priority=priority,
799
+ is_active=True
800
+ )
801
+ created.append({
802
+ 'id': str(qa.id),
803
+ 'question': qa.question,
804
+ 'answer': qa.answer[:50]
805
+ })
806
+ except Exception as e:
807
+ errors.append({'index': idx, 'question': question, 'error': str(e)})
808
+
809
+ return Response({
810
+ 'message': f'Created {len(created)} Global Q&A',
811
+ 'created': created,
812
+ 'errors': errors if errors else None
813
+ }, status=201 if created else 400)