Upload folder using huggingface_hub
Browse files- .idea/AI_chatbot.iml +11 -0
- .idea/inspectionProfiles/profiles_settings.xml +6 -0
- .idea/modules.xml +8 -0
- .idea/workspace.xml +42 -0
- __init__.py +0 -0
- __pycache__/__init__.cpython-312.pyc +0 -0
- __pycache__/admin.cpython-312.pyc +0 -0
- __pycache__/apps.cpython-312.pyc +0 -0
- __pycache__/consumers.cpython-312.pyc +0 -0
- __pycache__/greeting_question.cpython-312.pyc +0 -0
- __pycache__/models.cpython-312.pyc +0 -0
- __pycache__/serializers.cpython-312.pyc +0 -0
- __pycache__/services.cpython-312.pyc +0 -0
- __pycache__/urls.cpython-312.pyc +0 -0
- __pycache__/views.cpython-312.pyc +0 -0
- admin.py +68 -0
- apps.py +77 -0
- consumers.py +510 -0
- greeting_question.py +44 -0
- management/__init__.py +0 -0
- management/__pycache__/__init__.cpython-312.pyc +0 -0
- management/commands/__init__.py +0 -0
- management/commands/__pycache__/__init__.cpython-312.pyc +0 -0
- management/commands/__pycache__/setup_default_qa.cpython-312.pyc +0 -0
- management/commands/__pycache__/setup_global_qa.cpython-312.pyc +0 -0
- management/commands/__pycache__/setup_master_questions.cpython-312.pyc +0 -0
- management/commands/__pycache__/test_model.cpython-312.pyc +0 -0
- management/commands/__pycache__/websocket_test.cpython-312.pyc +0 -0
- management/commands/setup_global_qa.py +65 -0
- management/commands/setup_master_questions.py +42 -0
- migrations/0001_initial.py +160 -0
- migrations/__init__.py +1 -0
- migrations/__pycache__/0001_initial.cpython-312.pyc +0 -0
- migrations/__pycache__/__init__.cpython-312.pyc +0 -0
- models.py +190 -0
- models/fine_tuned_bilingual_model_v2/1_Pooling/config.json +5 -0
- models/fine_tuned_bilingual_model_v2/README.md +341 -0
- models/fine_tuned_bilingual_model_v2/config.json +30 -0
- models/fine_tuned_bilingual_model_v2/config_sentence_transformers.json +14 -0
- models/fine_tuned_bilingual_model_v2/model.safetensors +3 -0
- models/fine_tuned_bilingual_model_v2/modules.json +20 -0
- models/fine_tuned_bilingual_model_v2/sentence_bert_config.json +10 -0
- models/fine_tuned_bilingual_model_v2/tokenizer.json +0 -0
- models/fine_tuned_bilingual_model_v2/tokenizer_config.json +23 -0
- serializers.py +54 -0
- services.py +488 -0
- tests.py +3 -0
- urls.py +27 -0
- 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)
|