This commit is contained in:
Marwan Alwali 2025-05-26 16:37:24 +03:00
parent 250e0aa7bb
commit d26f777c73
6 changed files with 1105 additions and 971 deletions

View File

@ -1,49 +1,31 @@
from django.db.models import Avg, Sum, Max, Min, ForeignKey, OneToOneField
import inspect
import hashlib
from django.db import models
from django.db.models import Avg, Sum, Max, Min, ForeignKey, OneToOneField, Count
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
def _localized_keys(language):
"""
Get localized key names based on language.
:param language: Language code ('en' or 'ar')
:type language: str
:return: Dictionary of localized keys
:rtype: dict
"""
if language == 'ar':
return {
'type': 'نوع', 'model': 'النموذج', 'count': 'العدد', 'filters': 'الفلاتر_المطبقة',
'error': 'خطأ', 'chart_type': 'نوع_الرسم_البياني', 'labels': 'التسميات', 'data': 'البيانات',
'visualization_data': 'بيانات_الرسم_البياني', 'field': 'الحقل', 'value': 'القيمة',
'statistic_type': 'نوع_الإحصاء', 'results': 'النتائج', 'title': 'العنوان'
}
else:
return {
'type': 'type', 'model': 'model', 'count': 'count', 'filters': 'filters_applied',
'error': 'error', 'chart_type': 'chart_type', 'labels': 'labels', 'data': 'data',
'visualization_data': 'visualization_data', 'field': 'field', 'value': 'value',
'statistic_type': 'statistic_type', 'results': 'results', 'title': 'title'
}
return {
'type': 'نوع' if language == 'ar' else 'type',
'model': 'النموذج' if language == 'ar' else 'model',
'count': 'العدد' if language == 'ar' else 'count',
'filters': 'الفلاتر_المطبقة' if language == 'ar' else 'filters_applied',
'error': 'خطأ' if language == 'ar' else 'error',
'chart_type': 'نوع_الرسم_البياني' if language == 'ar' else 'chart_type',
'labels': 'التسميات' if language == 'ar' else 'labels',
'data': 'البيانات' if language == 'ar' else 'data',
'visualization_data': 'بيانات_الرسم_البياني' if language == 'ar' else 'visualization_data',
'field': 'الحقل' if language == 'ar' else 'field',
'value': 'القيمة' if language == 'ar' else 'value',
'statistic_type': 'نوع_الإحصاء' if language == 'ar' else 'statistic_type',
'results': 'النتائج' if language == 'ar' else 'results',
'title': 'العنوان' if language == 'ar' else 'title',
}
def generate_count_insight(models, query_params, dealer_id=None, language='en'):
"""
Generate count insights for the specified models.
:param models: List of models to analyze
:type models: list
:param query_params: Query parameters for filtering
:type query_params: dict
:param dealer_id: Dealer ID for filtering
:type dealer_id: int
:param language: Language code ('en' or 'ar')
:type language: str
:return: Count insights
:rtype: dict
"""
keys = _localized_keys(language)
results = []
@ -59,7 +41,9 @@ def generate_count_insight(models, query_params, dealer_id=None, language='en'):
filters = {}
for key, value in query_params.items():
if key not in ['field', 'operation'] and hasattr(model, key):
if key in ['field', 'operation']:
continue
if hasattr(model, key):
try:
field = model._meta.get_field(key)
if isinstance(field, models.IntegerField):
@ -76,13 +60,12 @@ def generate_count_insight(models, query_params, dealer_id=None, language='en'):
results.append({
keys['model']: model.__name__,
keys['count']: queryset.count(),
keys['filters']: filters
keys['filters']: filters,
})
except Exception as e:
results.append({
keys['model']: model.__name__,
keys['error']: str(e)
keys['error']: str(e),
})
return {
@ -91,30 +74,17 @@ def generate_count_insight(models, query_params, dealer_id=None, language='en'):
keys['visualization_data']: {
keys['chart_type']: 'bar',
keys['labels']: [r[keys['model']] for r in results if keys['count'] in r],
keys['data']: [r[keys['count']] for r in results if keys['count'] in r]
keys['data']: [r[keys['count']] for r in results if keys['count'] in r],
}
}
def generate_statistics_insight(models, query_params, dealer_id=None, language='en'):
"""
Generate statistical insights for the specified models.
:param models: List of models to analyze
:type models: list
:param query_params: Query parameters for filtering
:type query_params: dict
:param dealer_id: Dealer ID for filtering
:type dealer_id: int
:param language: Language code ('en' or 'ar')
:type language: str
:return: Statistical insights
:rtype: dict
"""
keys = _localized_keys(language)
results = []
field = query_params.get('field')
operation = query_params.get('operation', 'average')
stat_map = {'average': Avg, 'sum': Sum, 'max': Max, 'min': Min}
for model in models:
try:
@ -128,37 +98,28 @@ def generate_statistics_insight(models, query_params, dealer_id=None, language='
elif hasattr(model, 'dealer'):
queryset = queryset.filter(dealer=dealer_id)
filters = {}
for k, v in query_params.items():
if k not in ['field', 'operation'] and hasattr(model, k):
filters[k] = v
filters = {
k: v for k, v in query_params.items()
if k not in ['field', 'operation'] and hasattr(model, k)
}
if filters:
queryset = queryset.filter(**filters)
stat_map = {
'average': Avg,
'sum': Sum,
'max': Max,
'min': Min
}
if operation in stat_map:
agg = queryset.aggregate(val=stat_map[operation](field))['val']
value = agg
else:
value = queryset.count()
value = queryset.aggregate(val=stat_map.get(operation, Count)(field))['val']
results.append({
keys['model']: model.__name__,
keys['field']: field,
keys['statistic_type']: operation,
keys['value']: value,
keys['filters']: filters
keys['filters']: filters,
})
except Exception as e:
results.append({keys['model']: model.__name__, keys['error']: str(e)})
results.append({
keys['model']: model.__name__,
keys['error']: str(e),
})
return {
keys['type']: keys['type'] + '_analysis',
@ -173,18 +134,6 @@ def generate_statistics_insight(models, query_params, dealer_id=None, language='
def generate_recommendations(model_classes, analysis_type, language='en'):
"""
Generate recommendations based on model analysis.
:param model_classes: List of models to analyze
:type model_classes: list
:param analysis_type: Type of analysis
:type analysis_type: str
:param language: Language code ('en' or 'ar')
:type language: str
:return: List of recommendations
:rtype: list
"""
recs = []
for model in model_classes:
for field in model._meta.fields:
@ -198,34 +147,20 @@ def generate_recommendations(model_classes, analysis_type, language='en'):
def generate_model_insight(model, dealer_id=None, language='en'):
"""
Generate insights for a specific model.
:param model: Model to analyze
:type model: Model class
:param dealer_id: Dealer ID for filtering
:type dealer_id: int
:param language: Language code ('en' or 'ar')
:type language: str
:return: Model insights
:rtype: dict
"""
keys = _localized_keys(language)
fields_info = [
{
'name': f.name,
'type': f.__class__.__name__,
'null': f.null,
'blank': f.blank,
'unique': f.unique,
'pk': f.primary_key
} for f in model._meta.fields
]
fields_info = [{
'name': f.name,
'type': f.__class__.__name__,
'null': f.null,
'blank': f.blank,
'unique': f.unique,
'pk': f.primary_key
} for f in model._meta.fields]
try:
qs = model.objects.all()
if dealer_id:
if hasattr(model, 'dealer_id'):
if hasattr(model, 'dealer'):
qs = qs.filter(dealer_id=dealer_id)
elif hasattr(model, 'dealer'):
qs = qs.filter(dealer=dealer_id)
@ -242,20 +177,6 @@ def generate_model_insight(model, dealer_id=None, language='en'):
def generate_relationship_insight(models, query_params=None, dealer_id=None, language='en'):
"""
Generate relationship insights between models.
:param models: List of models to analyze
:type models: list
:param query_params: Query parameters (unused)
:type query_params: dict
:param dealer_id: Dealer ID (unused)
:type dealer_id: int
:param language: Language code ('en' or 'ar')
:type language: str
:return: Relationship insights
:rtype: dict
"""
from_ = "من" if language == 'ar' else "from"
to_ = "إلى" if language == 'ar' else "to"
rel_type = "نوع" if language == 'ar' else "type"
@ -267,7 +188,7 @@ def generate_relationship_insight(models, query_params=None, dealer_id=None, lan
relationships.append({
from_: model.__name__,
to_: field.related_model.__name__,
rel_type: field.__class__.__name__
rel_type: field.__class__.__name__,
})
for field in model._meta.many_to_many:
relationships.append({
@ -283,37 +204,24 @@ def generate_relationship_insight(models, query_params=None, dealer_id=None, lan
def generate_performance_insight(models, query_params=None, dealer_id=None, language='en'):
"""
Generate performance insights for models.
:param models: List of models to analyze
:type models: list
:param query_params: Query parameters (unused)
:type query_params: dict
:param dealer_id: Dealer ID (unused)
:type dealer_id: int
:param language: Language code ('en' or 'ar')
:type language: str
:return: Performance insights
:rtype: dict
"""
issues = []
for model in models:
for field in model._meta.fields:
if isinstance(field, ForeignKey) and not field.db_index:
issues.append({
'model': model.__name__,
# 'model': model.__name__,
'field': field.name,
'issue': 'Missing index on ForeignKey'
})
if isinstance(field, models.CharField) and not field.db_index and field.name in ['name', 'title']:
issues.append({
'model': model.__name__,
'field': field.name,
'issue': 'Unindexed CharField used in filtering'
})
# if isinstance(field, models.CharField) and not field.db_index and field.name in ['name', 'title']:
# issues.append({
# 'model': model.__name__,
# 'field': field.name,
# 'issue': 'Unindexed CharField used in filtering'
# })
return {
'type': 'تحليل_الأداء' if language == 'ar' else 'performance_analysis',
'issues': issues
}
}

View File

@ -1,93 +1,53 @@
import hashlib
import logging
from django.utils import timezone
from django.db import models
from ..models import AnalysisCache
import hashlib
import logging
logger = logging.getLogger(__name__)
class CacheService:
"""
Service for handling analysis result caching operations.
This service provides methods for generating cache keys, retrieving
cached results, and storing new results in the cache.
"""
def generate_hash(self, prompt, dealer_id, language):
"""
Generate a unique hash for the prompt, dealer, and language combination.
:param prompt: The user's prompt text
:type prompt: str
:param dealer_id: The dealer's ID
:type dealer_id: int
:param language: The language code
:type language: str
:return: MD5 hash string
:rtype: str
Generate a unique MD5 hash based on the prompt, dealer ID, and language.
"""
cache_key = f"{prompt}:{dealer_id or 'all'}:{language}"
return hashlib.md5(cache_key.encode()).hexdigest()
key = f"{prompt}:{dealer_id or 'all'}:{language}"
return hashlib.md5(key.encode()).hexdigest()
def get_cached_result(self, prompt_hash, user, dealer_id):
"""
Retrieve a cached result if available and not expired.
:param prompt_hash: The hash key for the cache entry
:type prompt_hash: str
:param user: The user making the request
:type user: User
:param dealer_id: The dealer's ID
:type dealer_id: int
:return: Cached result or None if not found
:rtype: dict or None
Retrieve a cached analysis result based on hash, dealer, and optionally user.
"""
try:
cache_entry = AnalysisCache.objects.filter(
prompt_hash=prompt_hash,
dealer_id=dealer_id,
expires_at__gt=timezone.now()
).first()
# If user is authenticated, also check user-specific cache
# Check for user-specific cache if authenticated
if user and user.is_authenticated:
user_cache = AnalysisCache.objects.filter(
prompt_hash=prompt_hash,
user=user,
expires_at__gt=timezone.now()
).first()
# User-specific cache takes precedence
if user_cache:
return user_cache.result
return cache_entry.result if cache_entry else None
# Otherwise check for dealer-wide cache
dealer_cache = AnalysisCache.objects.filter(
prompt_hash=prompt_hash,
dealer_id=dealer_id,
expires_at__gt=timezone.now()
).first()
return dealer_cache.result if dealer_cache else None
except Exception as e:
logger.warning(f"Error retrieving cache: {str(e)}")
logger.warning(f"Cache retrieval failed: {str(e)}")
return None
def cache_result(self, prompt_hash, result, user, dealer_id, duration=3600):
"""
Store a result in the cache.
:param prompt_hash: The hash key for the cache entry
:type prompt_hash: str
:param result: The result to cache
:type result: dict
:param user: The user making the request
:type user: User
:param dealer_id: The dealer's ID
:type dealer_id: int
:param duration: Cache duration in seconds
:type duration: int
:return: None
Save or update a cached result with an expiration timestamp.
"""
try:
# Calculate expiration time
expires_at = timezone.now() + timezone.timedelta(seconds=duration)
# Create or update cache entry
AnalysisCache.objects.update_or_create(
prompt_hash=prompt_hash,
user=user if user and user.is_authenticated else None,
@ -98,4 +58,4 @@ class CacheService:
}
)
except Exception as e:
logger.warning(f"Error caching result: {str(e)}")
logger.warning(f"Cache saving failed: {str(e)}")

View File

@ -1,25 +1,18 @@
import json
import logging
from django.apps import apps
from django.http import JsonResponse
from django.db.models import Count, Avg, Max, Min
from langchain_ollama import OllamaLLM
from langchain.chains import LLMChain
from langchain.prompts import PromptTemplate
from django.conf import settings
import logging
logger = logging.getLogger(__name__)
def get_llm_instance():
"""
Initialize and return an Ollama LLM instance configured for Arabic support.
This function creates a new LLM instance with optimized parameters for
both Arabic and English language processing. It reads configuration from
Django settings or uses sensible defaults.
:return: Configured OllamaLLM instance or None if initialization fails
:rtype: OllamaLLM or None
"""
try:
# Get settings from Django settings or use defaults
base_url = getattr(settings, 'OLLAMA_BASE_URL', 'http://localhost:11434')
model = getattr(settings, 'OLLAMA_MODEL', 'qwen3:8b')
temperature = getattr(settings, 'OLLAMA_TEMPERATURE', 0.2)
@ -27,7 +20,7 @@ def get_llm_instance():
top_k = getattr(settings, 'OLLAMA_TOP_K', 40)
num_ctx = getattr(settings, 'OLLAMA_NUM_CTX', 4096)
num_predict = getattr(settings, 'OLLAMA_NUM_PREDICT', 2048)
return OllamaLLM(
base_url=base_url,
model=model,
@ -45,23 +38,10 @@ def get_llm_instance():
def get_llm_chain(language='en'):
"""
Create a LangChain for analyzing prompts in Arabic or English.
This function creates a chain that processes user prompts and extracts
structured information about the analysis request. It supports both
Arabic and English languages.
:param language: Language code ('en' or 'ar')
:type language: str
:return: LangChain for prompt analysis or None if initialization fails
:rtype: LLMChain or None
"""
llm = get_llm_instance()
if not llm:
return None
# Define the prompt template based on language
if language == 'ar':
template = """
قم بتحليل الاستعلام التالي وتحديد نوع التحليل المطلوب ونماذج البيانات المستهدفة وأي معلمات استعلام.
@ -89,11 +69,82 @@ def get_llm_chain(language='en'):
}
"""
# Create the prompt template
prompt_template = PromptTemplate(
input_variables=["prompt"],
template=template
)
# Create and return the LLM chain
return prompt_template | llm
def analyze_models_with_orm(analysis_type, target_models, query_params):
results = {}
for model_name in target_models:
try:
model = apps.get_model('your_app_name', model_name)
except LookupError:
results[model_name] = {"error": f"Model '{model_name}' not found"}
continue
try:
queryset = model.objects.filter(**query_params)
if analysis_type == 'count':
results[model_name] = {'count': queryset.count()}
elif analysis_type == 'statistics':
numeric_fields = [f.name for f in model._meta.fields if f.get_internal_type() in ['IntegerField', 'FloatField', 'DecimalField']]
stats = {}
for field in numeric_fields:
stats[field] = {
'avg': queryset.aggregate(avg=Avg(field))['avg'],
'max': queryset.aggregate(max=Max(field))['max'],
'min': queryset.aggregate(min=Min(field))['min']
}
results[model_name] = stats
elif analysis_type == 'relationship':
related = {}
for field in model._meta.get_fields():
if field.is_relation and field.many_to_one:
related[field.name] = queryset.values(field.name).annotate(count=Count(field.name)).count()
results[model_name] = related
elif analysis_type == 'performance':
results[model_name] = {'note': 'Performance analysis logic not implemented.'}
else:
results[model_name] = list(queryset.values())
except Exception as e:
results[model_name] = {'error': str(e)}
return results
def analyze_prompt_and_return_json(request):
try:
prompt = request.POST.get('prompt')
language = request.POST.get('language', 'en')
chain = get_llm_chain(language)
if not chain:
return JsonResponse({'success': False, 'error': 'LLM not initialized'})
result = chain.invoke({'prompt': prompt})
parsed = json.loads(result)
analysis_type = parsed.get('analysis_type')
target_models = parsed.get('target_models', [])
query_params = parsed.get('query_params', {})
if not analysis_type or not target_models:
return JsonResponse({'success': False, 'error': 'Incomplete analysis instruction returned by LLM'})
orm_results = analyze_models_with_orm(analysis_type, target_models, query_params)
return JsonResponse({'success': True, 'data': orm_results})
except Exception as e:
return JsonResponse({'success': False, 'error': str(e)})

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -3,455 +3,521 @@
{% block title %}
{{ _("Haikalbot") }}
{% endblock %}
{% endblock title %}
{% block description %}
AI assistant
{% endblock %}
{% endblock description %}
{% block customCSS %}
<!-- No custom CSS as requested -->
{% endblock %}
<style>
.chart-container {
width: 100%;
margin-top: 1rem;
}
.chat-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.chat-textarea {
border-radius: 20px;
padding: 15px;
resize: none;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
border: 1px solid #dee2e6;
transition: all 0.3s ease;
}
.chat-textarea:focus {
border-color: #86b7fe;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
outline: none;
}
.send-button {
border-radius: 20px;
padding: 10px 25px;
font-weight: 500;
transition: all 0.3s ease;
}
.textarea-container {
position: relative;
}
.textarea-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
font-size: 0.8rem;
color: #6c757d;
}
.character-count.warning {
color: #dc3545;
font-weight: bold;
}
</style>
{% endblock customCSS %}
{% block content %}
<style>
.chat-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.chat-textarea {
border-radius: 20px;
padding: 15px;
resize: none;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
border: 1px solid #dee2e6;
transition: all 0.3s ease;
}
.chat-textarea:focus {
border-color: #86b7fe;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
outline: none;
}
.send-button {
border-radius: 20px;
padding: 10px 25px;
font-weight: 500;
transition: all 0.3s ease;
}
.textarea-container {
position: relative;
}
.textarea-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
font-size: 0.8rem;
color: #6c757d;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<div class="card shadow-none mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center fw-bolder fs-3 d-inline-block">
<img class="d-dark-none" src="{% static 'images/favicons/haikalbot_v1.png' %}" alt="{% trans 'home' %}" width="32" />
<img class="d-light-none" src="{% static 'images/favicons/haikalbot_v2.png' %}" alt="{% trans 'home' %}" width="32" />
</div>
<div class="d-flex gap-3">
<span id="clearChatBtn" class="translate-middle-y cursor-pointer" title="{% if LANGUAGE_CODE == 'ar' %}مسح المحادثة{% else %}Clear Chat{% endif %}">
<i class="fas fa-trash-alt text-danger"></i>
</span>
<span id="exportChatBtn" class="translate-middle-y cursor-pointer" title="{% if LANGUAGE_CODE == 'ar' %}تصدير المحادثة{% else %}Export Chat{% endif %}">
<i class="fas fa-download text-success"></i>
</span>
</div>
<img class="d-dark-none" src="{% static 'images/favicons/haikalbot_v1.png' %}" alt="{% trans 'home' %}" width="32" />
<img class="d-light-none" src="{% static 'images/favicons/haikalbot_v2.png' %}" alt="{% trans 'home' %}" width="32" />
</div>
<div class="d-flex gap-3">
<span id="clearChatBtn" class="translate-middle-y cursor-pointer" title="{% if LANGUAGE_CODE == 'ar' %}مسح المحادثة{% else %}Clear Chat{% endif %}">
<i class="fas fa-trash-alt text-danger"></i>
</span>
<span id="exportChatBtn" class="translate-middle-y cursor-pointer" title="{% if LANGUAGE_CODE == 'ar' %}تصدير المحادثة{% else %}Export Chat{% endif %}">
<i class="fas fa-download text-success"></i>
</span>
</div>
</div>
<div class="card-body p-0">
<div id="chatMessages" class="overflow-auto p-3" style="height: 60vh;">
<!-- Chat messages will be appended here -->
</div>
<div id="chatMessages" class="overflow-auto p-3" style="height: 60vh;"></div>
<div class="bg-100 border-top p-3">
<div class="d-flex gap-2 flex-wrap mb-3" id="suggestionChips">
<button class="btn btn-sm btn-outline-primary suggestion-chip">
{{ _("How many cars are in inventory")}}?
</button>
<button class="btn btn-sm btn-outline-primary suggestion-chip">
{{ _("Show me sales analysis")}}
</button>
<button class="btn btn-sm btn-outline-primary suggestion-chip">
{{ _("What are the best-selling cars")}}?
</button>
<button class="btn btn-sm btn-outline-primary suggestion-chip">{{ _("How many cars are in inventory")}}?</button>
<button class="btn btn-sm btn-outline-primary suggestion-chip">{{ _("Show me sales analysis")}}</button>
<button class="btn btn-sm btn-outline-primary suggestion-chip">{{ _("What are the best-selling cars")}}?</button>
</div>
<div class="chat-container">
<div class="textarea-container mb-3">
<label for="messageInput"></label>
<textarea class="form-control chat-textarea" id="messageInput" rows="3"
placeholder="{{ _("Type your message here")}}..."></textarea>
<div class="textarea-footer">
<div class="character-count">
<span id="charCount">0</span>/400
</div>
<span class="send-button position-absolute top-50 end-0 translate-middle-y cursor-pointer"
id="sendMessageBtn" disabled>
<i class="fas fa-paper-plane text-body"></i>
</span>
</div>
</div>
<div class="chat-container">
<div class="textarea-container mb-3">
<label for="messageInput"></label>
<textarea class="form-control chat-textarea" id="messageInput" rows="3" placeholder="{{ _("Type your message here")}}..." maxlength="400"></textarea>
<div class="textarea-footer">
<div class="character-count">
<span id="charCount">0</span>/400
</div>
<span class="send-button position-absolute top-50 end-0 translate-middle-y cursor-pointer" id="sendMessageBtn" disabled>
<i class="fas fa-paper-plane text-body"></i>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
$(document).ready(function() {
const messageInput = $('#messageInput');
const charCount = $('#charCount');
const sendMessageBtn = $('#sendMessageBtn');
const chatMessages = $('#chatMessages');
const clearChatBtn = $('#clearChatBtn');
const exportChatBtn = $('#exportChatBtn');
const suggestionChips = $('.suggestion-chip');
// Global configuration
const MAX_MESSAGE_LENGTH = 400;
const WARNING_THRESHOLD = 350;
const isArabic = '{{ LANGUAGE_CODE }}' === 'ar';
// Enable/disable send button based on input
messageInput.on('input', function() {
sendMessageBtn.prop('disabled', !messageInput.val().trim());
// Chart rendering function
function renderInsightChart(labels, data, chartType = 'bar', title = 'Insight Chart') {
const canvasId = 'chart_' + Date.now();
const chartHtml = `<div class="chart-container"><canvas id="${canvasId}"></canvas></div>`;
$('#chatMessages').append(chartHtml);
// Auto-resize textarea
this.style.height = 'auto';
this.style.height = (this.scrollHeight) + 'px';
});
new Chart(document.getElementById(canvasId), {
type: chartType,
data: {
labels: labels,
datasets: [{
label: title,
data: data,
borderWidth: 1,
backgroundColor: 'rgba(75, 192, 192, 0.5)',
borderColor: 'rgba(75, 192, 192, 1)',
}]
},
options: {
responsive: true,
plugins: {
legend: { display: true },
title: { display: true, text: title }
},
scales: {
y: { beginAtZero: true }
}
}
});
}
// Send message on Enter key (but allow Shift+Enter for new line)
messageInput.on('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!sendMessageBtn.prop('disabled')) {
sendMessage();
}
$(document).ready(function() {
// DOM elements
const messageInput = $('#messageInput');
const charCount = $('#charCount');
const sendMessageBtn = $('#sendMessageBtn');
const chatMessages = $('#chatMessages');
const clearChatBtn = $('#clearChatBtn');
const exportChatBtn = $('#exportChatBtn');
const suggestionChips = $('.suggestion-chip');
// Initialize character count
updateCharacterCount();
// Event handlers
messageInput.on('input', handleInput);
messageInput.on('keydown', handleKeyDown);
sendMessageBtn.on('click', sendMessage);
suggestionChips.on('click', handleSuggestionClick);
clearChatBtn.on('click', clearChat);
exportChatBtn.on('click', exportChat);
// Input handling
function handleInput() {
updateCharacterCount();
autoResizeTextarea();
toggleSendButton();
}
function handleKeyDown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!sendMessageBtn.prop('disabled')) {
sendMessage();
}
}
}
function handleSuggestionClick() {
messageInput.val($(this).text().trim());
updateCharacterCount();
autoResizeTextarea();
sendMessageBtn.prop('disabled', false);
sendMessage();
}
// UI utilities
function updateCharacterCount() {
const currentLength = messageInput.val().length;
charCount.text(currentLength);
if (currentLength > WARNING_THRESHOLD) {
charCount.parent().addClass('warning');
} else {
charCount.parent().removeClass('warning');
}
}
function autoResizeTextarea() {
messageInput[0].style.height = 'auto';
messageInput[0].style.height = (messageInput[0].scrollHeight) + 'px';
}
function toggleSendButton() {
sendMessageBtn.prop('disabled', !messageInput.val().trim());
}
// Chat actions
function sendMessage() {
const message = messageInput.val().trim();
if (!message) return;
// Add user message to chat
addMessage(message, true);
// Clear input and reset UI
resetInputUI();
// Show typing indicator
showTypingIndicator();
// Send to backend
$.ajax({
url: '{% url "haikalbot:haikalbot" %}',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
prompt: message,
language: '{{ LANGUAGE_CODE }}'
}),
headers: {
'X-CSRFToken': '{{ csrf_token }}'
},
success: function(response) {
processBotResponse(response);
},
error: function(xhr, status, error) {
handleRequestError(error);
}
});
}
// Send message on button click
sendMessageBtn.on('click', sendMessage);
function resetInputUI() {
messageInput.val('').css('height', 'auto');
charCount.text('0').parent().removeClass('warning');
sendMessageBtn.prop('disabled', true);
}
// Use suggestion chips
suggestionChips.on('click', function() {
messageInput.val($(this).text().trim());
sendMessageBtn.prop('disabled', false);
sendMessage();
});
function processBotResponse(response) {
hideTypingIndicator();
// Clear chat
clearChatBtn.on('click', function() {
if (confirm('{% if LANGUAGE_CODE == "ar" %}هل أنت متأكد من أنك تريد مسح المحادثة؟{% else %}Are you sure you want to clear the chat?{% endif %}')) {
// Keep only the first welcome message
const welcomeMessage = chatMessages.children().first();
chatMessages.empty().append(welcomeMessage);
}
});
// Debug response structure
console.log("API Response:", response);
// Export chat
exportChatBtn.on('click', function() {
let chatContent = '';
$('.message').each(function() {
const isUser = $(this).hasClass('user-message');
const sender = isUser ? '{% if LANGUAGE_CODE == "ar" %}أنت{% else %}You{% endif %}' : '{% if LANGUAGE_CODE == "ar" %}المساعد الذكي{% else %}AI Assistant{% endif %}';
const text = $(this).find('.chat-message').text().trim();
const time = $(this).find('.text-400').text().trim();
let botResponse = '';
chatContent += `${sender} (${time}):\n${text}\n\n`;
});
// Create and trigger download
const blob = new Blob([chatContent], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'chat-export-' + new Date().toISOString().slice(0, 10) + '.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
// Copy message text
$(document).on('click', '.copy-btn', function() {
const text = $(this).closest('.d-flex').find('.chat-message').text().trim();
navigator.clipboard.writeText(text).then(() => {
// Show temporary success indicator
const originalIcon = $(this).html();
$(this).html('<i class="fas fa-check"></i>');
setTimeout(() => {
$(this).html(originalIcon);
}, 1500);
});
});
// Function to send message
function sendMessage() {
const message = messageInput.val().trim();
if (!message) return;
// Add user message to chat
addMessage(message, true);
// Clear input and reset height
messageInput.val('').css('height', 'auto');
sendMessageBtn.prop('disabled', true);
// Show typing indicator
showTypingIndicator();
// Send to backend
$.ajax({
url: '{% url "haikalbot:haikalbot" %}',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
prompt: message,
language: '{{ LANGUAGE_CODE }}'
}),
headers: {
'X-CSRFToken': '{{ csrf_token }}'
},
success: function(response) {
// Hide typing indicator
hideTypingIndicator();
// Process response
let botResponse = '';
if (response.response) {
botResponse = response.response;
} else if (response.insights) {
// Format insights as a readable response
botResponse = formatInsightsResponse(response);
} else {
botResponse = '{% if LANGUAGE_CODE == "ar" %}عذرًا، لم أتمكن من معالجة طلبك.{% else %}Sorry, I couldn\'t process your request.{% endif %}';
}
// Add bot response to chat
addMessage(botResponse, false);
// Scroll to bottom
scrollToBottom();
},
error: function(xhr, status, error) {
// Hide typing indicator
hideTypingIndicator();
// Add error message
const errorMsg = '{% if LANGUAGE_CODE == "ar" %}عذرًا، حدث خطأ أثناء معالجة طلبك. يرجى المحاولة مرة أخرى.{% else %}Sorry, an error occurred while processing your request. Please try again.{% endif %}';
addMessage(errorMsg, false);
console.error('Error:', error);
}
});
// Check for direct response first
if (response.response) {
botResponse = response.response;
}
// Then check for insights data
else if (hasInsightsData(response)) {
botResponse = formatInsightsResponse(response);
}
// Fallback
else {
botResponse = isArabic ?
'عذرًا، لم أتمكن من معالجة طلبك. يبدو أن هيكل الاستجابة غير متوقع.' :
'Sorry, I couldn\'t process your request. The response structure appears unexpected.';
console.error("Unexpected response structure:", response);
}
// Function to add message to chat
function addMessage(text, isUser) {
const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const messageClass = isUser ? 'user-message justify-content-end' : '';
let avatarHtml = '';
let messageHtml = '';
if (isUser) {
// User message
avatarHtml = `
<div class="avatar avatar-l ms-3 order-1">
<div class="avatar-name rounded-circle bg-primary text-white"><span><i class="fas fa-user"></i></span></div>
</div>
`;
// Process text (no markdown for user messages)
messageHtml = `
<div class="flex-1 order-0">
<div class="w-xxl-75 ms-auto">
<div class="d-flex hover-actions-trigger align-items-center">
<div class="hover-actions start-0 top-50 translate-middle-y">
<button class="btn btn-phoenix-secondary btn-icon fs--2 round-btn copy-btn" type="button" title="{% if LANGUAGE_CODE == 'ar' %}نسخ{% else %}Copy{% endif %}">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="chat-message p-3 rounded-2">
${text}
</div>
</div>
<div class="text-400 fs--2">
${time}
</div>
</div>
</div>
`;
} else {
// Bot message
avatarHtml = `
<div class="me-3">
<div class="d-flex align-items-center fw-bolder fs-3 d-inline-block">
<img class="d-dark-none" src="{% static 'images/favicons/haikalbot_v1.png' %}" width="32" />
<img class="d-light-none" src="{% static 'images/favicons/haikalbot_v2.png' %}" width="32" />
</div>
</div>
`;
// Process markdown for bot messages
const processedText = marked.parse(text);
messageHtml = `
<div class="flex-1">
<div class="w-xxl-75">
<div class="d-flex hover-actions-trigger align-items-center">
<div class="chat-message bg-200 p-3 rounded-2">
${processedText}
</div>
<div class="hover-actions end-0 top-50 translate-middle-y">
<button class="btn btn-phoenix-secondary btn-icon fs--2 round-btn copy-btn" type="button" title="{{_("Copy")}}">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="text-400 fs--2 text-end">
${time}
</div>
</div>
</div>
`;
}
const fullMessageHtml = `
<div class="message d-flex mb-3 ${messageClass}">
${avatarHtml}
${messageHtml}
</div>
`;
chatMessages.append(fullMessageHtml);
scrollToBottom();
}
// Function to show typing indicator
function showTypingIndicator() {
const typingHtml = `
<div class="message d-flex mb-3" id="typingIndicator">
<div class="avatar avatar-l me-3">
<div class="avatar-name rounded-circle"><span><i class="fas fa-robot"></i></span></div>
</div>
<div class="flex-1 d-flex align-items-center">
<div class="spinner-border text-phoenix-secondary me-2" role="status"></div>
<span class="fs-9">{% if LANGUAGE_CODE == 'ar' %}جاري الكتابة...{% else %}Typing...{% endif %}</span>
</div>
</div>
`;
chatMessages.append(typingHtml);
scrollToBottom();
}
// Function to hide typing indicator
function hideTypingIndicator() {
$('#typingIndicator').remove();
}
// Function to scroll chat to bottom
function scrollToBottom() {
chatMessages.scrollTop(chatMessages[0].scrollHeight);
}
// Function to format insights response
function formatInsightsResponse(response) {
let formattedResponse = '';
const insightsKey = '{{ LANGUAGE_CODE }}' === 'ar' ? 'التحليلات' : 'insights';
const recsKey = '{{ LANGUAGE_CODE }}' === 'ar' ? 'التوصيات' : 'recommendations';
if (response[insightsKey] && response[insightsKey].length > 0) {
formattedResponse += '{{ LANGUAGE_CODE }}' === 'ar' ? '## نتائج التحليل\n\n' : '## Analysis Results\n\n';
response[insightsKey].forEach(insight => {
if (insight.type) {
formattedResponse += `### ${insight.type}\n\n`;
}
if (insight.results) {
insight.results.forEach(result => {
if (result.error) {
formattedResponse += `- **${result.model || ''}**: ${result.error}\n`;
} else if (result.count !== undefined) {
formattedResponse += `- **${result.model || ''}**: ${result.count}\n`;
} else if (result.value !== undefined) {
const fieldKey = '{{ LANGUAGE_CODE }}' === 'ar' ? 'الحقل' : 'field';
const statTypeKey = '{{ LANGUAGE_CODE }}' === 'ar' ? 'نوع_الإحصاء' : 'statistic_type';
formattedResponse += `- **${result.model || ''}**: ${result[statTypeKey]} of ${result[fieldKey]} = ${result.value}\n`;
}
});
formattedResponse += '\n';
}
if (insight.relationships) {
formattedResponse += '{{ LANGUAGE_CODE }}' === 'ar' ? ' العلاقات:\n\n' : ' Relationships:\n\n';
insight.relationships.forEach(rel => {
const fromKey = '{{ LANGUAGE_CODE }}' === 'ar' ? 'من' : 'from';
const toKey = '{{ LANGUAGE_CODE }}' === 'ar' ? 'إلى' : 'to';
const typeKey = '{{ LANGUAGE_CODE }}' === 'ar' ? 'نوع' : 'type';
formattedResponse += `- ${rel[fromKey]} → ${rel[toKey]} (${rel[typeKey]})\n`;
});
formattedResponse += '\n';
}
});
}
if (response[recsKey] && response[recsKey].length > 0) {
formattedResponse += '{{ LANGUAGE_CODE }}' === 'ar' ? ' التوصيات\n\n' : ' Recommendations\n\n';
response[recsKey].forEach(rec => {
formattedResponse += `- ${rec}\n`;
});
}
return formattedResponse || '{{ LANGUAGE_CODE }}' === 'ar' ? 'تم تحليل البيانات بنجاح.' : 'Data analyzed successfully.';
}
// Initialize
addMessage(botResponse, false);
scrollToBottom();
});
messageInput.addEventListener('input', function() {
const currentLength = this.value.length;
charCount.textContent = currentLength;
}
// Optional: Add warning when approaching limit
if (currentLength > 350) {
charCount.style.color = 'red';
} else {
charCount.style.color = 'inherit';
}
function hasInsightsData(response) {
return response.insights || response['التحليلات'] ||
response.recommendations || response['التوصيات'];
}
function handleRequestError(error) {
hideTypingIndicator();
const errorMsg = isArabic ?
'عذرًا، حدث خطأ أثناء معالجة طلبك. يرجى المحاولة مرة أخرى.' :
'Sorry, an error occurred while processing your request. Please try again.';
addMessage(errorMsg, false);
console.error('API Error:', error);
}
// Chat management
function clearChat() {
if (confirm(isArabic ?
'هل أنت متأكد من أنك تريد مسح المحادثة؟' :
'Are you sure you want to clear the chat?')) {
const welcomeMessage = chatMessages.children().first();
chatMessages.empty().append(welcomeMessage);
}
}
function exportChat() {
let chatContent = '';
$('.message').each(function() {
const isUser = $(this).hasClass('user-message');
const sender = isUser ?
(isArabic ? 'أنت' : 'You') :
(isArabic ? 'المساعد الذكي' : 'AI Assistant');
const text = $(this).find('.chat-message').text().trim();
const time = $(this).find('.text-400').text().trim();
chatContent += `${sender} (${time}):\n${text}\n\n`;
});
downloadTextFile(chatContent, 'chat-export-' + new Date().toISOString().slice(0, 10) + '.txt');
}
function downloadTextFile(content, filename) {
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Message display functions
function addMessage(text, isUser) {
const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const messageClass = isUser ? 'user-message justify-content-between' : '';
const avatarHtml = getAvatarHtml(isUser);
const messageHtml = getMessageHtml(text, isUser, time);
const fullMessageHtml = `
<div class="message d-flex mb-3 ${messageClass}">
${avatarHtml}
${messageHtml}
</div>
`;
chatMessages.append(fullMessageHtml);
scrollToBottom();
}
function getAvatarHtml(isUser) {
if (isUser) {
return `
<div class="avatar avatar-l ms-3 order-1">
<div class="avatar-name rounded-circle">
<span><i class="fas fa-user"></i></span>
</div>
</div>
`;
}
return `
<div class="me-3">
<div class="d-flex align-items-center fw-bolder fs-3 d-inline-block">
<img class="d-dark-none" src="{% static 'images/favicons/haikalbot_v1.png' %}" width="32" />
<img class="d-light-none" src="{% static 'images/favicons/haikalbot_v2.png' %}" width="32" />
</div>
</div>
`;
}
function getMessageHtml(text, isUser, time) {
if (isUser) {
return `
<div class="flex-1 order-0">
<div class="w-xxl-75 ms-auto">
<div class="d-flex hover-actions-trigger align-items-center">
<div class="hover-actions start-0 top-50 translate-middle-y">
<button class="btn btn-phoenix-secondary btn-icon fs--2 round-btn copy-btn" type="button" title="${isArabic ? 'نسخ' : 'Copy'}">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="chat-message p-3 rounded-2">
${text}
</div>
</div>
<div class="text-400 fs--2">
${time}
</div>
</div>
</div>
`;
}
const processedText = marked.parse(text);
return `
<div class="flex-1">
<div class="w-xxl-75">
<div class="d-flex hover-actions-trigger align-items-center">
<div class="chat-message bg-200 p-3 rounded-2">
${processedText}
</div>
<div class="hover-actions end-0 top-50 translate-middle-y">
<button class="btn btn-phoenix-secondary btn-icon fs--2 round-btn copy-btn" type="button" title="${isArabic ? 'نسخ' : 'Copy'}">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="text-400 fs--2 text-end">
${time}
</div>
</div>
</div>
`;
}
function showTypingIndicator() {
const typingHtml = `
<div class="message d-flex mb-3" id="typingIndicator">
<div class="avatar avatar-l me-3">
<div class="avatar-name rounded-circle">
<span><i class="fas fa-robot"></i></span>
</div>
</div>
<div class="flex-1 d-flex align-items-center">
<div class="spinner-border text-phoenix-secondary me-2" role="status"></div>
<span class="fs-9">${isArabic ? 'جاري الكتابة...' : 'Typing...'}</span>
</div>
</div>
`;
chatMessages.append(typingHtml);
scrollToBottom();
}
function hideTypingIndicator() {
$('#typingIndicator').remove();
}
function scrollToBottom() {
chatMessages.scrollTop(chatMessages[0].scrollHeight);
}
// Insights formatting
function formatInsightsResponse(response) {
console.log("Formatting insights response:", response);
let formattedResponse = '';
// Get data using both possible key formats
const insightsData = response.insights || response['التحليلات'] || [];
const recommendationsData = response.recommendations || response['التوصيات'] || [];
// Process insights
if (insightsData.length > 0) {
formattedResponse += isArabic ? '## نتائج التحليل\n\n' : '## Analysis Results\n\n';
insightsData.forEach(insight => {
if (insight.type) {
formattedResponse += `### ${insight.type}\n\n`;
}
if (insight.results && Array.isArray(insight.results)) {
insight.results.forEach(result => {
if (result.error) {
formattedResponse += `- **${result.model || ''}**: ${result.error}\n`;
} else if (result.count !== undefined) {
formattedResponse += `- **${result.model || ''}**: ${result.count}\n`;
} else if (result.value !== undefined) {
const field = getLocalizedValue(result, 'field', 'الحقل');
const statType = getLocalizedValue(result, 'statistic_type', 'نوع_الإحصاء');
formattedResponse += `- **${result.model || ''}**: ${statType} ${isArabic ? 'لـ' : 'of'} ${field} = ${result.value}\n`;
}
});
formattedResponse += '\n';
}
if (insight.relationships && Array.isArray(insight.relationships)) {
formattedResponse += isArabic ? '### العلاقات\n\n' : '### Relationships\n\n';
insight.relationships.forEach(rel => {
const from = getLocalizedValue(rel, 'from', 'من');
const to = getLocalizedValue(rel, 'to', 'إلى');
const type = getLocalizedValue(rel, 'type', 'نوع');
formattedResponse += `- ${from} → ${to} (${type})\n`;
});
formattedResponse += '\n';
}
});
}
// Process recommendations
if (recommendationsData.length > 0) {
formattedResponse += isArabic ? '## التوصيات\n\n' : '## Recommendations\n\n';
recommendationsData.forEach(rec => {
formattedResponse += `- ${rec}\n`;
});
}
return formattedResponse.trim() ||
(isArabic ? 'تم تحليل البيانات بنجاح ولكن لا توجد نتائج للعرض.' : 'Data analyzed successfully but no results to display.');
}
function getLocalizedValue(obj, englishKey, arabicKey) {
return isArabic ? (obj[arabicKey] || obj[englishKey] || '') : (obj[englishKey] || obj[arabicKey] || '');
}
// Copy message functionality
$(document).on('click', '.copy-btn', function() {
const text = $(this).closest('.d-flex').find('.chat-message').text().trim();
navigator.clipboard.writeText(text).then(() => {
showCopySuccess($(this));
});
});
function showCopySuccess(button) {
const originalIcon = button.html();
button.html('<i class="fas fa-check"></i>');
setTimeout(() => {
button.html(originalIcon);
}, 1500);
}
// Initialize
scrollToBottom();
});
</script>
{% endblock %}
{% block customJS %}
<!-- JS will be loaded from static file or added separately -->
{% endblock %}
{% endblock content %}