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 inspect
import hashlib
from django.db import models 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.translation import gettext_lazy as _
from django.utils import timezone
def _localized_keys(language): 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 { return {
'type': 'نوع', 'model': 'النموذج', 'count': 'العدد', 'filters': 'الفلاتر_المطبقة', 'type': 'نوع' if language == 'ar' else 'type',
'error': 'خطأ', 'chart_type': 'نوع_الرسم_البياني', 'labels': 'التسميات', 'data': 'البيانات', 'model': 'النموذج' if language == 'ar' else 'model',
'visualization_data': 'بيانات_الرسم_البياني', 'field': 'الحقل', 'value': 'القيمة', 'count': 'العدد' if language == 'ar' else 'count',
'statistic_type': 'نوع_الإحصاء', 'results': 'النتائج', 'title': 'العنوان' 'filters': 'الفلاتر_المطبقة' if language == 'ar' else 'filters_applied',
} 'error': 'خطأ' if language == 'ar' else 'error',
else: 'chart_type': 'نوع_الرسم_البياني' if language == 'ar' else 'chart_type',
return { 'labels': 'التسميات' if language == 'ar' else 'labels',
'type': 'type', 'model': 'model', 'count': 'count', 'filters': 'filters_applied', 'data': 'البيانات' if language == 'ar' else 'data',
'error': 'error', 'chart_type': 'chart_type', 'labels': 'labels', 'data': 'data', 'visualization_data': 'بيانات_الرسم_البياني' if language == 'ar' else 'visualization_data',
'visualization_data': 'visualization_data', 'field': 'field', 'value': 'value', 'field': 'الحقل' if language == 'ar' else 'field',
'statistic_type': 'statistic_type', 'results': 'results', 'title': 'title' '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'): 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) keys = _localized_keys(language)
results = [] results = []
@ -59,7 +41,9 @@ def generate_count_insight(models, query_params, dealer_id=None, language='en'):
filters = {} filters = {}
for key, value in query_params.items(): 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: try:
field = model._meta.get_field(key) field = model._meta.get_field(key)
if isinstance(field, models.IntegerField): if isinstance(field, models.IntegerField):
@ -76,13 +60,12 @@ def generate_count_insight(models, query_params, dealer_id=None, language='en'):
results.append({ results.append({
keys['model']: model.__name__, keys['model']: model.__name__,
keys['count']: queryset.count(), keys['count']: queryset.count(),
keys['filters']: filters keys['filters']: filters,
}) })
except Exception as e: except Exception as e:
results.append({ results.append({
keys['model']: model.__name__, keys['model']: model.__name__,
keys['error']: str(e) keys['error']: str(e),
}) })
return { return {
@ -91,30 +74,17 @@ def generate_count_insight(models, query_params, dealer_id=None, language='en'):
keys['visualization_data']: { keys['visualization_data']: {
keys['chart_type']: 'bar', keys['chart_type']: 'bar',
keys['labels']: [r[keys['model']] for r in results if keys['count'] in r], 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'): 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) keys = _localized_keys(language)
results = [] results = []
field = query_params.get('field') field = query_params.get('field')
operation = query_params.get('operation', 'average') operation = query_params.get('operation', 'average')
stat_map = {'average': Avg, 'sum': Sum, 'max': Max, 'min': Min}
for model in models: for model in models:
try: try:
@ -128,37 +98,28 @@ def generate_statistics_insight(models, query_params, dealer_id=None, language='
elif hasattr(model, 'dealer'): elif hasattr(model, 'dealer'):
queryset = queryset.filter(dealer=dealer_id) queryset = queryset.filter(dealer=dealer_id)
filters = {} filters = {
for k, v in query_params.items(): k: v for k, v in query_params.items()
if k not in ['field', 'operation'] and hasattr(model, k): if k not in ['field', 'operation'] and hasattr(model, k)
filters[k] = v }
if filters: if filters:
queryset = queryset.filter(**filters) queryset = queryset.filter(**filters)
stat_map = { value = queryset.aggregate(val=stat_map.get(operation, Count)(field))['val']
'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()
results.append({ results.append({
keys['model']: model.__name__, keys['model']: model.__name__,
keys['field']: field, keys['field']: field,
keys['statistic_type']: operation, keys['statistic_type']: operation,
keys['value']: value, keys['value']: value,
keys['filters']: filters keys['filters']: filters,
}) })
except Exception as e: 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 { return {
keys['type']: keys['type'] + '_analysis', 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'): 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 = [] recs = []
for model in model_classes: for model in model_classes:
for field in model._meta.fields: 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'): 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) keys = _localized_keys(language)
fields_info = [ fields_info = [{
{
'name': f.name, 'name': f.name,
'type': f.__class__.__name__, 'type': f.__class__.__name__,
'null': f.null, 'null': f.null,
'blank': f.blank, 'blank': f.blank,
'unique': f.unique, 'unique': f.unique,
'pk': f.primary_key 'pk': f.primary_key
} for f in model._meta.fields } for f in model._meta.fields]
]
try: try:
qs = model.objects.all() qs = model.objects.all()
if dealer_id: if dealer_id:
if hasattr(model, 'dealer_id'): if hasattr(model, 'dealer'):
qs = qs.filter(dealer_id=dealer_id) qs = qs.filter(dealer_id=dealer_id)
elif hasattr(model, 'dealer'): elif hasattr(model, 'dealer'):
qs = qs.filter(dealer=dealer_id) 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'): 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" from_ = "من" if language == 'ar' else "from"
to_ = "إلى" if language == 'ar' else "to" to_ = "إلى" if language == 'ar' else "to"
rel_type = "نوع" if language == 'ar' else "type" 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({ relationships.append({
from_: model.__name__, from_: model.__name__,
to_: field.related_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: for field in model._meta.many_to_many:
relationships.append({ relationships.append({
@ -283,35 +204,22 @@ 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'): 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 = [] issues = []
for model in models: for model in models:
for field in model._meta.fields: for field in model._meta.fields:
if isinstance(field, ForeignKey) and not field.db_index: if isinstance(field, ForeignKey) and not field.db_index:
issues.append({ issues.append({
'model': model.__name__, # 'model': model.__name__,
'field': field.name, 'field': field.name,
'issue': 'Missing index on ForeignKey' 'issue': 'Missing index on ForeignKey'
}) })
if isinstance(field, models.CharField) and not field.db_index and field.name in ['name', 'title']: # if isinstance(field, models.CharField) and not field.db_index and field.name in ['name', 'title']:
issues.append({ # issues.append({
'model': model.__name__, # 'model': model.__name__,
'field': field.name, # 'field': field.name,
'issue': 'Unindexed CharField used in filtering' # 'issue': 'Unindexed CharField used in filtering'
}) # })
return { return {
'type': 'تحليل_الأداء' if language == 'ar' else 'performance_analysis', 'type': 'تحليل_الأداء' if language == 'ar' else 'performance_analysis',

View File

@ -1,93 +1,53 @@
import hashlib
import logging
from django.utils import timezone from django.utils import timezone
from django.db import models from django.db import models
from ..models import AnalysisCache from ..models import AnalysisCache
import hashlib
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class CacheService: 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): def generate_hash(self, prompt, dealer_id, language):
""" """
Generate a unique hash for the prompt, dealer, and language combination. Generate a unique MD5 hash based on the prompt, dealer ID, and language.
: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
""" """
cache_key = f"{prompt}:{dealer_id or 'all'}:{language}" key = f"{prompt}:{dealer_id or 'all'}:{language}"
return hashlib.md5(cache_key.encode()).hexdigest() return hashlib.md5(key.encode()).hexdigest()
def get_cached_result(self, prompt_hash, user, dealer_id): def get_cached_result(self, prompt_hash, user, dealer_id):
""" """
Retrieve a cached result if available and not expired. Retrieve a cached analysis result based on hash, dealer, and optionally user.
: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
""" """
try: try:
cache_entry = AnalysisCache.objects.filter( # Check for user-specific cache if authenticated
prompt_hash=prompt_hash,
dealer_id=dealer_id,
expires_at__gt=timezone.now()
).first()
# If user is authenticated, also check user-specific cache
if user and user.is_authenticated: if user and user.is_authenticated:
user_cache = AnalysisCache.objects.filter( user_cache = AnalysisCache.objects.filter(
prompt_hash=prompt_hash, prompt_hash=prompt_hash,
user=user, user=user,
expires_at__gt=timezone.now() expires_at__gt=timezone.now()
).first() ).first()
# User-specific cache takes precedence
if user_cache: if user_cache:
return user_cache.result 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: except Exception as e:
logger.warning(f"Error retrieving cache: {str(e)}") logger.warning(f"Cache retrieval failed: {str(e)}")
return None return None
def cache_result(self, prompt_hash, result, user, dealer_id, duration=3600): def cache_result(self, prompt_hash, result, user, dealer_id, duration=3600):
""" """
Store a result in the cache. Save or update a cached result with an expiration timestamp.
: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
""" """
try: try:
# Calculate expiration time
expires_at = timezone.now() + timezone.timedelta(seconds=duration) expires_at = timezone.now() + timezone.timedelta(seconds=duration)
# Create or update cache entry
AnalysisCache.objects.update_or_create( AnalysisCache.objects.update_or_create(
prompt_hash=prompt_hash, prompt_hash=prompt_hash,
user=user if user and user.is_authenticated else None, user=user if user and user.is_authenticated else None,
@ -98,4 +58,4 @@ class CacheService:
} }
) )
except Exception as e: 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_ollama import OllamaLLM
from langchain.chains import LLMChain from langchain.chains import LLMChain
from langchain.prompts import PromptTemplate from langchain.prompts import PromptTemplate
from django.conf import settings from django.conf import settings
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_llm_instance(): 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: try:
# Get settings from Django settings or use defaults
base_url = getattr(settings, 'OLLAMA_BASE_URL', 'http://localhost:11434') base_url = getattr(settings, 'OLLAMA_BASE_URL', 'http://localhost:11434')
model = getattr(settings, 'OLLAMA_MODEL', 'qwen3:8b') model = getattr(settings, 'OLLAMA_MODEL', 'qwen3:8b')
temperature = getattr(settings, 'OLLAMA_TEMPERATURE', 0.2) temperature = getattr(settings, 'OLLAMA_TEMPERATURE', 0.2)
@ -45,23 +38,10 @@ def get_llm_instance():
def get_llm_chain(language='en'): 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() llm = get_llm_instance()
if not llm: if not llm:
return None return None
# Define the prompt template based on language
if language == 'ar': if language == 'ar':
template = """ template = """
قم بتحليل الاستعلام التالي وتحديد نوع التحليل المطلوب ونماذج البيانات المستهدفة وأي معلمات استعلام. قم بتحليل الاستعلام التالي وتحديد نوع التحليل المطلوب ونماذج البيانات المستهدفة وأي معلمات استعلام.
@ -89,11 +69,82 @@ def get_llm_chain(language='en'):
} }
""" """
# Create the prompt template
prompt_template = PromptTemplate( prompt_template = PromptTemplate(
input_variables=["prompt"], input_variables=["prompt"],
template=template template=template
) )
# Create and return the LLM chain
return prompt_template | llm 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,18 +3,18 @@
{% block title %} {% block title %}
{{ _("Haikalbot") }} {{ _("Haikalbot") }}
{% endblock %} {% endblock title %}
{% block description %} {% block description %}
AI assistant AI assistant
{% endblock %} {% endblock description %}
{% block customCSS %} {% block customCSS %}
<!-- No custom CSS as requested --> <style>
{% endblock %} .chart-container {
width: 100%;
{% block content %} margin-top: 1rem;
<style> }
.chat-container { .chat-container {
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
@ -50,16 +50,22 @@ AI assistant
font-size: 0.8rem; font-size: 0.8rem;
color: #6c757d; color: #6c757d;
} }
</style> .character-count.warning {
color: #dc3545;
font-weight: bold;
}
</style>
{% endblock customCSS %}
{% block content %}
<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 shadow-none mb-3">
<div class="card-header d-flex justify-content-between align-items-center"> <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"> <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-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" /> <img class="d-light-none" src="{% static 'images/favicons/haikalbot_v2.png' %}" alt="{% trans 'home' %}" width="32" />
</div> </div>
<div class="d-flex gap-3"> <div class="d-flex gap-3">
<span id="clearChatBtn" class="translate-middle-y cursor-pointer" title="{% if LANGUAGE_CODE == 'ar' %}مسح المحادثة{% else %}Clear Chat{% endif %}"> <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> <i class="fas fa-trash-alt text-danger"></i>
@ -69,52 +75,71 @@ AI assistant
</span> </span>
</div> </div>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div id="chatMessages" class="overflow-auto p-3" style="height: 60vh;"> <div id="chatMessages" class="overflow-auto p-3" style="height: 60vh;"></div>
<!-- Chat messages will be appended here -->
</div>
<div class="bg-100 border-top p-3"> <div class="bg-100 border-top p-3">
<div class="d-flex gap-2 flex-wrap mb-3" id="suggestionChips"> <div class="d-flex gap-2 flex-wrap mb-3" id="suggestionChips">
<button class="btn btn-sm btn-outline-primary suggestion-chip"> <button class="btn btn-sm btn-outline-primary suggestion-chip">{{ _("How many cars are in inventory")}}?</button>
{{ _("How many cars are in inventory")}}? <button class="btn btn-sm btn-outline-primary suggestion-chip">{{ _("Show me sales analysis")}}</button>
</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">
{{ _("Show me sales analysis")}}
</button>
<button class="btn btn-sm btn-outline-primary suggestion-chip">
{{ _("What are the best-selling cars")}}?
</button>
</div> </div>
<div class="chat-container"> <div class="chat-container">
<div class="textarea-container mb-3"> <div class="textarea-container mb-3">
<label for="messageInput"></label> <label for="messageInput"></label>
<textarea class="form-control chat-textarea" id="messageInput" rows="3" <textarea class="form-control chat-textarea" id="messageInput" rows="3" placeholder="{{ _("Type your message here")}}..." maxlength="400"></textarea>
placeholder="{{ _("Type your message here")}}..."></textarea>
<div class="textarea-footer"> <div class="textarea-footer">
<div class="character-count"> <div class="character-count">
<span id="charCount">0</span>/400 <span id="charCount">0</span>/400
</div> </div>
<span class="send-button position-absolute top-50 end-0 translate-middle-y cursor-pointer" id="sendMessageBtn" disabled>
<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> <i class="fas fa-paper-plane text-body"></i>
</span> </span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script> <script>
$(document).ready(function() { // Global configuration
const MAX_MESSAGE_LENGTH = 400;
const WARNING_THRESHOLD = 350;
const isArabic = '{{ LANGUAGE_CODE }}' === 'ar';
// 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);
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 }
}
}
});
}
$(document).ready(function() {
// DOM elements
const messageInput = $('#messageInput'); const messageInput = $('#messageInput');
const charCount = $('#charCount'); const charCount = $('#charCount');
const sendMessageBtn = $('#sendMessageBtn'); const sendMessageBtn = $('#sendMessageBtn');
@ -123,82 +148,63 @@ AI assistant
const exportChatBtn = $('#exportChatBtn'); const exportChatBtn = $('#exportChatBtn');
const suggestionChips = $('.suggestion-chip'); const suggestionChips = $('.suggestion-chip');
// Enable/disable send button based on input // Initialize character count
messageInput.on('input', function() { updateCharacterCount();
sendMessageBtn.prop('disabled', !messageInput.val().trim());
// Auto-resize textarea // Event handlers
this.style.height = 'auto'; messageInput.on('input', handleInput);
this.style.height = (this.scrollHeight) + 'px'; messageInput.on('keydown', handleKeyDown);
}); sendMessageBtn.on('click', sendMessage);
suggestionChips.on('click', handleSuggestionClick);
clearChatBtn.on('click', clearChat);
exportChatBtn.on('click', exportChat);
// Send message on Enter key (but allow Shift+Enter for new line) // Input handling
messageInput.on('keydown', function(e) { function handleInput() {
updateCharacterCount();
autoResizeTextarea();
toggleSendButton();
}
function handleKeyDown(e) {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
if (!sendMessageBtn.prop('disabled')) { if (!sendMessageBtn.prop('disabled')) {
sendMessage(); sendMessage();
} }
} }
}); }
// Send message on button click function handleSuggestionClick() {
sendMessageBtn.on('click', sendMessage);
// Use suggestion chips
suggestionChips.on('click', function() {
messageInput.val($(this).text().trim()); messageInput.val($(this).text().trim());
updateCharacterCount();
autoResizeTextarea();
sendMessageBtn.prop('disabled', false); sendMessageBtn.prop('disabled', false);
sendMessage(); sendMessage();
});
// 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);
} }
});
// Export chat // UI utilities
exportChatBtn.on('click', function() { function updateCharacterCount() {
let chatContent = ''; const currentLength = messageInput.val().length;
$('.message').each(function() { charCount.text(currentLength);
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();
chatContent += `${sender} (${time}):\n${text}\n\n`; if (currentLength > WARNING_THRESHOLD) {
}); charCount.parent().addClass('warning');
} else {
charCount.parent().removeClass('warning');
}
}
// Create and trigger download function autoResizeTextarea() {
const blob = new Blob([chatContent], { type: 'text/plain' }); messageInput[0].style.height = 'auto';
const url = URL.createObjectURL(blob); messageInput[0].style.height = (messageInput[0].scrollHeight) + 'px';
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 function toggleSendButton() {
$(document).on('click', '.copy-btn', function() { sendMessageBtn.prop('disabled', !messageInput.val().trim());
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 // Chat actions
function sendMessage() { function sendMessage() {
const message = messageInput.val().trim(); const message = messageInput.val().trim();
if (!message) return; if (!message) return;
@ -206,9 +212,8 @@ AI assistant
// Add user message to chat // Add user message to chat
addMessage(message, true); addMessage(message, true);
// Clear input and reset height // Clear input and reset UI
messageInput.val('').css('height', 'auto'); resetInputUI();
sendMessageBtn.prop('disabled', true);
// Show typing indicator // Show typing indicator
showTypingIndicator(); showTypingIndicator();
@ -226,63 +231,147 @@ AI assistant
'X-CSRFToken': '{{ csrf_token }}' 'X-CSRFToken': '{{ csrf_token }}'
}, },
success: function(response) { success: function(response) {
// Hide typing indicator processBotResponse(response);
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) { error: function(xhr, status, error) {
// Hide typing indicator handleRequestError(error);
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);
} }
}); });
} }
// Function to add message to chat function resetInputUI() {
messageInput.val('').css('height', 'auto');
charCount.text('0').parent().removeClass('warning');
sendMessageBtn.prop('disabled', true);
}
function processBotResponse(response) {
hideTypingIndicator();
// Debug response structure
console.log("API Response:", response);
let botResponse = '';
// 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);
}
addMessage(botResponse, false);
scrollToBottom();
}
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) { function addMessage(text, isUser) {
const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const messageClass = isUser ? 'user-message justify-content-end' : ''; const messageClass = isUser ? 'user-message justify-content-between' : '';
let avatarHtml = ''; const avatarHtml = getAvatarHtml(isUser);
let messageHtml = ''; const messageHtml = getMessageHtml(text, isUser, time);
if (isUser) { const fullMessageHtml = `
// User message <div class="message d-flex mb-3 ${messageClass}">
avatarHtml = ` ${avatarHtml}
<div class="avatar avatar-l ms-3 order-1"> ${messageHtml}
<div class="avatar-name rounded-circle bg-primary text-white"><span><i class="fas fa-user"></i></span></div>
</div> </div>
`; `;
// Process text (no markdown for user messages) chatMessages.append(fullMessageHtml);
messageHtml = ` 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="flex-1 order-0">
<div class="w-xxl-75 ms-auto"> <div class="w-xxl-75 ms-auto">
<div class="d-flex hover-actions-trigger align-items-center"> <div class="d-flex hover-actions-trigger align-items-center">
<div class="hover-actions start-0 top-50 translate-middle-y"> <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 %}"> <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> <i class="fas fa-copy"></i>
</button> </button>
</div> </div>
@ -296,21 +385,11 @@ AI assistant
</div> </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); const processedText = marked.parse(text);
messageHtml = ` return `
<div class="flex-1"> <div class="flex-1">
<div class="w-xxl-75"> <div class="w-xxl-75">
<div class="d-flex hover-actions-trigger align-items-center"> <div class="d-flex hover-actions-trigger align-items-center">
@ -318,7 +397,7 @@ AI assistant
${processedText} ${processedText}
</div> </div>
<div class="hover-actions end-0 top-50 translate-middle-y"> <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")}}"> <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> <i class="fas fa-copy"></i>
</button> </button>
</div> </div>
@ -331,27 +410,17 @@ AI assistant
`; `;
} }
const fullMessageHtml = `
<div class="message d-flex mb-3 ${messageClass}">
${avatarHtml}
${messageHtml}
</div>
`;
chatMessages.append(fullMessageHtml);
scrollToBottom();
}
// Function to show typing indicator
function showTypingIndicator() { function showTypingIndicator() {
const typingHtml = ` const typingHtml = `
<div class="message d-flex mb-3" id="typingIndicator"> <div class="message d-flex mb-3" id="typingIndicator">
<div class="avatar avatar-l me-3"> <div class="avatar avatar-l me-3">
<div class="avatar-name rounded-circle"><span><i class="fas fa-robot"></i></span></div> <div class="avatar-name rounded-circle">
<span><i class="fas fa-robot"></i></span>
</div>
</div> </div>
<div class="flex-1 d-flex align-items-center"> <div class="flex-1 d-flex align-items-center">
<div class="spinner-border text-phoenix-secondary me-2" role="status"></div> <div class="spinner-border text-phoenix-secondary me-2" role="status"></div>
<span class="fs-9">{% if LANGUAGE_CODE == 'ar' %}جاري الكتابة...{% else %}Typing...{% endif %}</span> <span class="fs-9">${isArabic ? 'جاري الكتابة...' : 'Typing...'}</span>
</div> </div>
</div> </div>
`; `;
@ -360,98 +429,95 @@ AI assistant
scrollToBottom(); scrollToBottom();
} }
// Function to hide typing indicator
function hideTypingIndicator() { function hideTypingIndicator() {
$('#typingIndicator').remove(); $('#typingIndicator').remove();
} }
// Function to scroll chat to bottom
function scrollToBottom() { function scrollToBottom() {
chatMessages.scrollTop(chatMessages[0].scrollHeight); chatMessages.scrollTop(chatMessages[0].scrollHeight);
} }
// Function to format insights response // Insights formatting
function formatInsightsResponse(response) { function formatInsightsResponse(response) {
console.log("Formatting insights response:", response);
let formattedResponse = ''; let formattedResponse = '';
const insightsKey = '{{ LANGUAGE_CODE }}' === 'ar' ? 'التحليلات' : 'insights';
const recsKey = '{{ LANGUAGE_CODE }}' === 'ar' ? 'التوصيات' : 'recommendations';
if (response[insightsKey] && response[insightsKey].length > 0) { // Get data using both possible key formats
formattedResponse += '{{ LANGUAGE_CODE }}' === 'ar' ? '## نتائج التحليل\n\n' : '## Analysis Results\n\n'; const insightsData = response.insights || response['التحليلات'] || [];
const recommendationsData = response.recommendations || response['التوصيات'] || [];
response[insightsKey].forEach(insight => { // Process insights
if (insightsData.length > 0) {
formattedResponse += isArabic ? '## نتائج التحليل\n\n' : '## Analysis Results\n\n';
insightsData.forEach(insight => {
if (insight.type) { if (insight.type) {
formattedResponse += `### ${insight.type}\n\n`; formattedResponse += `### ${insight.type}\n\n`;
} }
if (insight.results) { if (insight.results && Array.isArray(insight.results)) {
insight.results.forEach(result => { insight.results.forEach(result => {
if (result.error) { if (result.error) {
formattedResponse += `- **${result.model || ''}**: ${result.error}\n`; formattedResponse += `- **${result.model || ''}**: ${result.error}\n`;
} else if (result.count !== undefined) { } else if (result.count !== undefined) {
formattedResponse += `- **${result.model || ''}**: ${result.count}\n`; formattedResponse += `- **${result.model || ''}**: ${result.count}\n`;
} else if (result.value !== undefined) { } else if (result.value !== undefined) {
const fieldKey = '{{ LANGUAGE_CODE }}' === 'ar' ? 'الحقل' : 'field'; const field = getLocalizedValue(result, 'field', 'الحقل');
const statTypeKey = '{{ LANGUAGE_CODE }}' === 'ar' ? 'نوع_الإحصاء' : 'statistic_type'; const statType = getLocalizedValue(result, 'statistic_type', 'نوع_الإحصاء');
formattedResponse += `- **${result.model || ''}**: ${result[statTypeKey]} of ${result[fieldKey]} = ${result.value}\n`; formattedResponse += `- **${result.model || ''}**: ${statType} ${isArabic ? 'لـ' : 'of'} ${field} = ${result.value}\n`;
} }
}); });
formattedResponse += '\n'; formattedResponse += '\n';
} }
if (insight.relationships) { if (insight.relationships && Array.isArray(insight.relationships)) {
formattedResponse += '{{ LANGUAGE_CODE }}' === 'ar' ? ' العلاقات:\n\n' : ' Relationships:\n\n'; formattedResponse += isArabic ? '### العلاقات\n\n' : '### Relationships\n\n';
insight.relationships.forEach(rel => { insight.relationships.forEach(rel => {
const fromKey = '{{ LANGUAGE_CODE }}' === 'ar' ? 'من' : 'from'; const from = getLocalizedValue(rel, 'from', 'من');
const toKey = '{{ LANGUAGE_CODE }}' === 'ar' ? 'إلى' : 'to'; const to = getLocalizedValue(rel, 'to', 'إلى');
const typeKey = '{{ LANGUAGE_CODE }}' === 'ar' ? 'نوع' : 'type'; const type = getLocalizedValue(rel, 'type', 'نوع');
formattedResponse += `- ${rel[fromKey]} → ${rel[toKey]} (${rel[typeKey]})\n`; formattedResponse += `- ${from} → ${to} (${type})\n`;
}); });
formattedResponse += '\n'; formattedResponse += '\n';
} }
}); });
} }
if (response[recsKey] && response[recsKey].length > 0) { // Process recommendations
formattedResponse += '{{ LANGUAGE_CODE }}' === 'ar' ? ' التوصيات\n\n' : ' Recommendations\n\n'; if (recommendationsData.length > 0) {
response[recsKey].forEach(rec => { formattedResponse += isArabic ? '## التوصيات\n\n' : '## Recommendations\n\n';
recommendationsData.forEach(rec => {
formattedResponse += `- ${rec}\n`; formattedResponse += `- ${rec}\n`;
}); });
} }
return formattedResponse || '{{ LANGUAGE_CODE }}' === 'ar' ? 'تم تحليل البيانات بنجاح.' : 'Data analyzed successfully.'; 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 // Initialize
scrollToBottom(); 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';
}
});
</script> </script>
{% endblock %} {% endblock content %}
{% block customJS %}
<!-- JS will be loaded from static file or added separately -->
{% endblock %}