Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 28 additions & 35 deletions chatbot/admin/company_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
from pydantic import ValidationError
from simple_history.admin import SimpleHistoryAdmin
from .generic_upload_admin import BatchUploadMixin
from chatbot.filter.admin_filter import (CompanyChatCompanyFilter, ChatSessionFilter, ProfileCityFilter,
ProfileStateFilter, ProfileCompanyChatFilter, ProfileEmailFilter)
from chatbot.filter.admin_filter import (CompanyChatCompanyFilter, ChatSessionFilter,
ProfileCompanyChatFilter, ProfileEmailFilter)
from chatbot.filter.custom_date_from_filter import CustomAdvanceDateFilter
from chatbot.models import Company, Profile, ProfileType, CompanyBot, CompanyChat, ChatSession, \
CompanyBotTypeChoices, Voice, VoiceProvider, VoiceType, ImageConfiguration, Flow
from chatbot.models import Company, Profile, ProfileType, CompanyBot, CompanyChat, CompanyChatFeedback, \
ChatSession, CompanyBotTypeChoices, Voice, VoiceProvider, VoiceType, ImageConfiguration, Flow
from chatbot.models.company_models import CompanyStateMachine
from chatbot.resources.resource import CompanyChatResource
from chatbot.resources.company_resource import ChatSessionResource
Expand Down Expand Up @@ -292,9 +292,30 @@ def changelist_view(self, request, extra_context=None):
duplicate_bot.short_description = "Duplicate selected bot"


class CompanyChatFeedbackInline(admin.TabularInline):
"""Read-only: feedback rows are created via the feedback API only and are never edited,
so admins can view the full history here but can't add/change/delete from this screen."""
model = CompanyChatFeedback
fk_name = 'company_chat'
extra = 0
fields = ('thumbs_up', 'thumbs_down', 'comment', 'created_at')
readonly_fields = fields
ordering = ('-created_at',)

def has_add_permission(self, request, obj=None):
return False

def has_change_permission(self, request, obj=None):
return False

def has_delete_permission(self, request, obj=None):
return False


@admin.register(CompanyChat)
class CompanyChatAdmin(ExportAllFieldsMixin, admin.ModelAdmin):
list_display = ('session', 'sender', 'receiver', 'message', 'translated_message', 'created_at', 'stage', 'status')
inlines = [CompanyChatFeedbackInline]
list_filter = (
CustomAdvanceDateFilter,
ProfileCompanyChatFilter,
Expand All @@ -313,44 +334,16 @@ class CompanyChatAdmin(ExportAllFieldsMixin, admin.ModelAdmin):
resource_class = CompanyChatResource

def get_queryset(self, request):
qs = super().get_queryset(request)
qs = super().get_queryset(request).prefetch_related('sender__company', 'receiver__company')
user_email = request.user.email
profile = Profile.objects.filter(email=user_email).select_related('company').first()
if request.user.is_superuser:
return qs.prefetch_related('sender__company', 'receiver__company')
return qs
elif profile and profile.profile_type == ProfileType.MODERATOR:
return qs.filter(
Q(sender__company=profile.company) | Q(receiver__company=profile.company)
).prefetch_related('sender__company', 'receiver__company')
return qs.filter(Q(sender__company=profile.company) | Q(receiver__company=profile.company))
else:
return qs.none()

def get_search_results(self, request, queryset, search_term):
queryset, use_distinct = super().get_search_results(request, queryset, search_term)

user_email = request.user.email
profile = Profile.objects.filter(email=user_email).select_related('company').first()
if not request.user.is_superuser and profile and profile.profile_type == ProfileType.MODERATOR:
if profile.company:
queryset = queryset.filter(
Q(sender__company=profile.company) | Q(receiver__company=profile.company)
).prefetch_related('sender__company', 'receiver__company')
return queryset, use_distinct

def get_list_filter(self, request):
user = request.user
user_email = request.user.email
profile = Profile.objects.filter(email=user_email).select_related('company').first()
if not user.is_superuser and profile and profile.profile_type == ProfileType.MODERATOR:
company = profile.company
if company.slug == 'fmch':
return (CustomAdvanceDateFilter, ProfileCompanyChatFilter,
ProfileEmailFilter, 'session', ProfileCityFilter, ProfileStateFilter, 'message_type')
if company.slug == 'tfistaging':
return (CustomAdvanceDateFilter, ProfileCompanyChatFilter,
ProfileEmailFilter, 'session', CompanyChatCompanyFilter, 'stage')
return super().get_list_filter(request)


@admin.register(ChatSession)
class ChatSessionAdmin(ExportAllFieldsMixin, admin.ModelAdmin):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Generated by Django 5.2 on 2026-07-30 09:49

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('chatbot', '0088_remove_botvernacular_bot_vernacu_company_483975_idx_and_more'),
]

operations = [
migrations.AlterField(
model_name='chatsession',
name='language',
field=models.CharField(choices=[('en', 'English'), ('hi', 'Hindi'), ('kn', 'Kannada'), ('te', 'Telugu'), ('or', 'Odia'), ('ta', 'Tamil')], default='en', max_length=1000),
),
migrations.AlterField(
model_name='story',
name='language',
field=models.CharField(choices=[('en', 'English'), ('hi', 'Hindi'), ('kn', 'Kannada'), ('te', 'Telugu'), ('or', 'Odia'), ('ta', 'Tamil')], default='en', max_length=1000),
),
migrations.AlterField(
model_name='storytranslation',
name='language',
field=models.CharField(choices=[('en', 'English'), ('hi', 'Hindi'), ('kn', 'Kannada'), ('te', 'Telugu'), ('or', 'Odia'), ('ta', 'Tamil')], max_length=10),
),
migrations.CreateModel(
name='CompanyChatFeedback',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('thumbs_up', models.BooleanField(default=False, help_text='True if the user gave a positive rating in this submission.')),
('thumbs_down', models.BooleanField(default=False, help_text='True if the user gave a negative rating in this submission. Cannot be True at the same time as thumbs_up (enforced in the serializer).')),
('comment', models.TextField(blank=True, help_text='Optional free-text feedback typed by the user.', null=True)),
('created_at', models.DateTimeField(auto_now_add=True, help_text='When this feedback was submitted. Immutable — also used to determine the current state (latest row wins) and submission order.')),
('company_chat', models.ForeignKey(help_text='The bot response (CompanyChat row) this feedback is about.', on_delete=django.db.models.deletion.CASCADE, related_name='feedbacks', to='chatbot.companychat')),
],
options={
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['company_chat', '-created_at'], name='chatbot_com_company_f623f2_idx')],
},
),
]
37 changes: 37 additions & 0 deletions chatbot/models/company_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,43 @@ def save(self, *args, **kwargs):
super(CompanyChat, self).save(*args, **kwargs)


class CompanyChatFeedback(models.Model):
"""
A single feedback submission (thumbs up/down + optional comment) for a bot response.
Rows are append-only — never updated — so the full history is preserved and the
most recent row (by created_at) represents the current state.
"""
company_chat = models.ForeignKey(
CompanyChat, related_name='feedbacks', on_delete=models.CASCADE,
help_text='The bot response (CompanyChat row) this feedback is about.'
)
thumbs_up = models.BooleanField(
default=False, help_text='True if the user gave a positive rating in this submission.'
)
thumbs_down = models.BooleanField(
default=False,
help_text='True if the user gave a negative rating in this submission. '
'Cannot be True at the same time as thumbs_up (enforced in the serializer).'
)
comment = models.TextField(
null=True, blank=True, help_text='Optional free-text feedback typed by the user.'
)
created_at = models.DateTimeField(
auto_now_add=True,
help_text='When this feedback was submitted. Immutable — also used to determine '
'the current state (latest row wins) and submission order.'
)

class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['company_chat', '-created_at']),
]

def __str__(self):
return f'Feedback #{self.id} for CompanyChat #{self.company_chat_id}'


class Voice(models.Model):
"""
Defines a text-to-speech voice configuration for a company bot.
Expand Down
72 changes: 71 additions & 1 deletion chatbot/serializer/profile_serializer.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from django.db import transaction
from rest_framework import serializers
from chatbot.models.media_models import ProfileMedia
from chatbot.models.profile_models import Profile
from chatbot.models.company_models import CompanyChat
from chatbot.models.company_models import CompanyChat, CompanyChatFeedback
from chatbot.models.geo_models import ProfileAddress
from chatbot.serializer.company_serializer import CompanySerializer

Expand Down Expand Up @@ -82,9 +83,78 @@ def update(self, instance, validated_data):
return instance

class CompanyChatSerializer(serializers.ModelSerializer):
"""Note: thumbs_up/thumbs_down reflect only the latest CompanyChatFeedback row for this
message (comment text and older feedback history are intentionally not exposed here)."""
sender = ProfileSerializer(read_only=True)
receiver = ProfileSerializer(read_only=True)
thumbs_up = serializers.SerializerMethodField()
thumbs_down = serializers.SerializerMethodField()

class Meta:
model = CompanyChat
fields = '__all__'

def _latest_feedback(self, obj):
# The view's queryset annotates latest_thumbs_up/latest_thumbs_down via a Subquery
# so the full feedback history is never loaded. Fall back to a direct query for
# instances not fetched through that queryset (e.g. a freshly created row on POST).
if hasattr(obj, 'latest_thumbs_up'):
return obj.latest_thumbs_up, obj.latest_thumbs_down
latest = obj.feedbacks.order_by('-created_at').first()
return (latest.thumbs_up, latest.thumbs_down) if latest else (None, None)

def get_thumbs_up(self, obj):
thumbs_up, _ = self._latest_feedback(obj)
return bool(thumbs_up)

def get_thumbs_down(self, obj):
_, thumbs_down = self._latest_feedback(obj)
return bool(thumbs_down)


class CompanyChatFeedbackSerializer(serializers.ModelSerializer):
"""Creates a new feedback row. Never updates an existing one — every submission
(including switching thumbs up <-> down) is stored as its own history entry."""

class Meta:
model = CompanyChatFeedback
fields = ['id', 'company_chat', 'thumbs_up', 'thumbs_down', 'comment', 'created_at']
read_only_fields = ['id', 'created_at']

def validate(self, attrs):
has_thumbs_key = 'thumbs_up' in self.initial_data or 'thumbs_down' in self.initial_data
has_comment = bool((attrs.get('comment') or '').strip())

if not has_thumbs_key and not has_comment:
raise serializers.ValidationError(
'At least one of thumbs_up, thumbs_down, or comment is required.'
)

# Explicit thumbs decisions are validated here; the comment-only carry-forward
# case is resolved atomically in create() to avoid a read-then-insert race with
# a concurrent feedback submission for the same company_chat.
if has_thumbs_key and attrs.get('thumbs_up', False) and attrs.get('thumbs_down', False):
raise serializers.ValidationError('thumbs_up and thumbs_down cannot both be true.')
return attrs

def create(self, validated_data):
has_thumbs_key = 'thumbs_up' in self.initial_data or 'thumbs_down' in self.initial_data
company_chat = validated_data['company_chat']

with transaction.atomic():
# Lock the parent row so ALL feedback submissions for this company_chat —
# explicit thumbs decisions and comment-only carry-forwards alike — serialize
# here. Without locking on the explicit-thumbs path too, a concurrent
# comment-only request could still read a stale "latest" and, since it's
# inserted later, overwrite a newer explicit decision.
CompanyChat.objects.select_for_update().get(pk=company_chat.pk)

if not has_thumbs_key:
latest = CompanyChatFeedback.objects.filter(
company_chat=company_chat
).order_by('-created_at').first()
if latest:
validated_data['thumbs_up'] = latest.thumbs_up
validated_data['thumbs_down'] = latest.thumbs_down

return super().create(validated_data)
39 changes: 34 additions & 5 deletions chatbot/services/response_handlers/base_response_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -812,11 +812,39 @@ def _prepare_sources(self, chunks):
continue
seen.add(key)
if url:
sources.append({'title': title, 'url': url})
source_entry = {'title': title, 'url': url}
else:
sources.append({'title': f'Referred: {title}'})
source_entry = {'title': f'Referred: {title}'}

chunk_source = chunk.get('source', '')
if chunk_source == 'web_search':
source_entry['source'] = 'web_search'
domain = self._extract_domain(url)
if domain:
source_entry['domain'] = domain
elif chunk_source == 'kb_search':
source_entry['source'] = 'kb_search'
company = chunk.get('company', '')
if company:
source_entry['company'] = company
logo = chunk.get('logo', '')
if logo:
source_entry['logo'] = logo

sources.append(source_entry)
return sources

@staticmethod
def _extract_domain(url):
"""Return just the site name from a URL's domain — no 'www.' prefix, no TLD (e.g. 'impriindia')."""
if not url:
return ''
from urllib.parse import urlparse
netloc = urlparse(url).netloc
if netloc.startswith('www.'):
netloc = netloc[len('www.'):]
return netloc.split('.')[0]

def _extract_citation_chunks(self, message):
"""Extract web search citations from a non-stream gateway message and return as chunk dicts."""
chunks = []
Expand All @@ -831,7 +859,8 @@ def _collect_from_tool_results(tool_results):
url = item.get('url', '')
title = item.get('title', '')
if url or title:
chunks.append({'text': item.get('cited_text', ''), 'title': title, 'url': url})
chunks.append({'text': item.get('cited_text', ''), 'title': title, 'url': url,
'source': 'web_search'})

citations_raw = message.get('citations') or []
if citations_raw and isinstance(citations_raw[0], dict) and 'content' in citations_raw[0]:
Expand All @@ -849,7 +878,7 @@ def _collect_from_tool_results(tool_results):
title = citation.get('title', '')
text = citation.get('cited_text', '')
if url or title:
chunks.append({'text': text, 'title': title, 'url': url})
chunks.append({'text': text, 'title': title, 'url': url, 'source': 'web_search'})

if not chunks:
# 'citations' can be null even when a web search happened — the raw provider
Expand All @@ -875,7 +904,7 @@ def _extract_citation_chunks_from_stream(self, citation_events):
title = item.get('title', '')
text = item.get('cited_text', '') or item.get('text', '')
if url or title:
chunks.append({'text': text, 'title': title, 'url': url})
chunks.append({'text': text, 'title': title, 'url': url, 'source': 'web_search'})
return chunks

def _parse_if_string(self, value, fallback):
Expand Down
6 changes: 5 additions & 1 deletion chatbot/services/vector/vector_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,15 @@ def _fetch_chunks(query, top_k, filter_score, priority):
score = item.get('score', 0)
text = item.get('text', '')
if text and len(text) > 20 and score >= filter_score:
metadata = item.get('metadata', {}) or {}
chunks.append({
'text': text,
'title': item.get('title', ''),
'url': item.get('metadata', {}).get('url', ''),
'url': metadata.get('url', ''),
'score': score,
'source': 'kb_search',
'company': metadata.get('company', ''),
'logo': metadata.get('logo', ''),
})
return chunks

Expand Down
3 changes: 3 additions & 0 deletions chatbot/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from chatbot.views.bhashini_views import text_speech_view, speech_text, text_translation_view, text_transliterate_view
from chatbot.views.chat_view import save_chats_view, create_chatsession, save_ptm_chats
from chatbot.views.drf_views import CompanyChatListCreateView, CompanyChatRetrieveUpdateDestroyView, \
CompanyChatFeedbackCreateView, \
CompanyBotListCreateView, CompanyBotRetrieveUpdateDestroyView, ProfileListCreateView, \
ProfileRetrieveUpdateDestroyView, ChatSessionListCreateView, ChatSessionRetrieveUpdateDestroyView, \
ChatSessionRetrieveUpdateDestroyViewSession, BotVernacularListCreateView, BotVernacularRetrieveUpdateDestroyView, \
Expand All @@ -43,6 +44,7 @@
# path('api/profile/', api_views.post_profile), # disabled: unvalidated profile creation
path('api/get-profile/', api_views.get_profile_view, name='get-profile'),
path('api/accept-tnc/', api_views.accept_tnc_view, name='accept-tnc'),
path('api/update-profile/', api_views.update_profile_view, name='update-profile'),
path('api/logout/', api_views.logout_profile, name='logout-profile'),
path('api/user_profile/', ProfileListCreateView.as_view(), name='profile-list-create'),

Expand All @@ -60,6 +62,7 @@
path('api/companychat/', CompanyChatListCreateView.as_view(), name='companychat-list-create'),
path('api/companychat/<int:pk>/', CompanyChatRetrieveUpdateDestroyView.as_view(),
name='companychat-retrieve-update-destroy'),
path('api/companychat-feedback/', CompanyChatFeedbackCreateView.as_view(), name='companychat-feedback-create'),

path('api/companybot/', CompanyBotListCreateView.as_view(), name='companybot-list-create'),
path('api/companybot/<int:pk>/', CompanyBotRetrieveUpdateDestroyView.as_view(),
Expand Down
Loading