diff --git a/app/controllers/api/v1/accounts/captain/reports/insights_controller.rb b/app/controllers/api/v1/accounts/captain/reports/insights_controller.rb index 7f0a66cf7..4a91e1685 100644 --- a/app/controllers/api/v1/accounts/captain/reports/insights_controller.rb +++ b/app/controllers/api/v1/accounts/captain/reports/insights_controller.rb @@ -1,13 +1,6 @@ class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Accounts::BaseController def index - unit_id = params[:unit_id].present? ? params[:unit_id].to_i : nil - inbox_id = params[:inbox_id].present? ? params[:inbox_id].to_i : nil - - scope = Captain::ConversationInsight.where(account_id: Current.account.id) - scope = scope.where(captain_unit_id: unit_id) if unit_id - scope = scope.where(inbox_id: inbox_id) if inbox_id - - insights = scope.order(period_start: :desc).limit(12) + insights = filtered_insights.order(period_start: :desc).limit(12) render json: insights.map { |i| format_insight(i) } end @@ -39,6 +32,22 @@ class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Account private + def filtered_insights + scope = Captain::ConversationInsight.where(account_id: Current.account.id) + scope = scope.where(captain_unit_id: filter_unit_id) if filter_unit_id + scope = scope.where(inbox_id: filter_inbox_id) if filter_inbox_id + scope = scope.for_period(*requested_period) if requested_period + scope + end + + def filter_unit_id + params[:unit_id].presence&.to_i + end + + def filter_inbox_id + params[:inbox_id].presence&.to_i + end + def enqueue_insight(unit_id, inbox_id, period_start, period_end) insight = find_or_init_insight(unit_id, inbox_id, period_start, period_end) return render json: { status: 'processing', message: 'Análise já está em andamento' } if insight.processing? @@ -77,6 +86,14 @@ class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Account default end + def requested_period + return nil if params[:period_start].blank? || params[:period_end].blank? + + [Date.parse(params[:period_start].to_s), Date.parse(params[:period_end].to_s)] + rescue ArgumentError, TypeError + nil + end + def format_insight(insight) { id: insight.id, diff --git a/app/javascript/dashboard/routes/dashboard/settings/captain/reports/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/captain/reports/Index.vue index 22746ea29..035a032d9 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/captain/reports/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/captain/reports/Index.vue @@ -253,12 +253,22 @@ const fetchLpStats = async () => { let pollInterval = null; +const insightFilterParams = () => { + const { period_start, period_end } = getPeriodDates(selectedPeriod.value); + return { + ...(selectedInboxId.value && { inbox_id: selectedInboxId.value }), + ...(period_start && period_end && { period_start, period_end }), + }; +}; + +const fetchInsightsForSelectedFilters = async () => { + await store.dispatch('captainReports/fetchInsights', insightFilterParams()); +}; + const startPolling = () => { if (pollInterval) return; pollInterval = setInterval(async () => { - await store.dispatch('captainReports/fetchInsights', { - inbox_id: selectedInboxId.value, - }); + await fetchInsightsForSelectedFilters(); }, 10000); }; @@ -289,6 +299,7 @@ watch(activeTab, async tab => { watch([customStartDate, customEndDate], async () => { if (selectedPeriod.value !== 'custom') return; if (!customStartDate.value || !customEndDate.value) return; + await fetchInsightsForSelectedFilters(); if (activeTab.value === 'landing_pages') await fetchLpStats(); if (activeTab.value === 'operational') await fetchOperational(); if (activeTab.value === 'executive') await fetchExecutive(); @@ -308,7 +319,7 @@ watch( onMounted(async () => { await store.dispatch('inboxes/get'); await store.dispatch('captainAssistants/get'); - await store.dispatch('captainReports/fetchInsights', {}); + await fetchInsightsForSelectedFilters(); if (hasProcessingInsights.value) startPolling(); await fetchLpStats(); }); @@ -320,9 +331,7 @@ onUnmounted(() => { const onFilterChange = async event => { const value = event.target.value; selectedInboxId.value = value ? Number(value) : null; - await store.dispatch('captainReports/fetchInsights', { - inbox_id: selectedInboxId.value, - }); + await fetchInsightsForSelectedFilters(); if (activeTab.value === 'landing_pages') await fetchLpStats(); if (activeTab.value === 'operational') await fetchOperational(); if (activeTab.value === 'executive') await fetchExecutive(); @@ -330,6 +339,7 @@ const onFilterChange = async event => { const onPeriodChange = async event => { selectedPeriod.value = event.target.value; + await fetchInsightsForSelectedFilters(); if (activeTab.value === 'landing_pages') await fetchLpStats(); if (activeTab.value === 'operational') await fetchOperational(); if (activeTab.value === 'executive') await fetchExecutive(); diff --git a/enterprise/app/jobs/captain/reports/generate_insights_job.rb b/enterprise/app/jobs/captain/reports/generate_insights_job.rb index 0936ffd1a..fc362a489 100644 --- a/enterprise/app/jobs/captain/reports/generate_insights_job.rb +++ b/enterprise/app/jobs/captain/reports/generate_insights_job.rb @@ -46,17 +46,21 @@ class Captain::Reports::GenerateInsightsJob < ApplicationJob account: account, unit: unit, inbox: inbox, - conversations: conversations + conversations: conversations, + period_start: period_start, + period_end: period_end ).analyze - insight.update!(messages_count: conversations.sum { |conv| conv.messages.count }) + insight.update!(messages_count: messages_in_period(conversations, period_start, period_end).count) insight.mark_done!(payload) end def fetch_conversations(account, unit, inbox, period_start, period_end) scope = account.conversations - .where(created_at: period_start.beginning_of_day..period_end.end_of_day) - .includes(:messages) + .joins(:messages) + .where(messages: { created_at: period_start.beginning_of_day..period_end.end_of_day }) + .where.not(messages: { message_type: [Message.message_types[:activity], Message.message_types[:template]] }) + .where(messages: { private: false }) if inbox scope = scope.where(inbox_id: inbox.id) @@ -65,6 +69,19 @@ class Captain::Reports::GenerateInsightsJob < ApplicationJob scope = scope.where(inbox_id: inbox_ids) if inbox_ids.any? end - scope.to_a + account.conversations + .where(id: scope.select(:id).distinct) + .includes(:inbox, :contact, :messages) + .to_a + end + + def messages_in_period(conversations, period_start, period_end) + conversation_ids = conversations.map(&:id) + return Message.none if conversation_ids.empty? + + Message.where(conversation_id: conversation_ids) + .where(created_at: period_start.beginning_of_day..period_end.end_of_day) + .where.not(message_type: %i[activity template]) + .where(private: false) end end diff --git a/enterprise/app/services/captain/llm/conversation_insight_service.rb b/enterprise/app/services/captain/llm/conversation_insight_service.rb index 8dd65ec77..7e835e012 100644 --- a/enterprise/app/services/captain/llm/conversation_insight_service.rb +++ b/enterprise/app/services/captain/llm/conversation_insight_service.rb @@ -3,12 +3,14 @@ class Captain::Llm::ConversationInsightService < Llm::BaseAiService MAX_CHARS_PER_CHUNK = 40_000 - def initialize(account:, conversations:, unit: nil, inbox: nil) + def initialize(account:, conversations:, unit: nil, inbox: nil, period_start: nil, period_end: nil) super() @account = account @unit = unit @inbox = inbox @conversations = conversations + @period_start = period_start + @period_end = period_end end # Analisa as conversas e retorna o payload de insights @@ -24,10 +26,10 @@ class Captain::Llm::ConversationInsightService < Llm::BaseAiService private - attr_reader :account, :unit, :inbox, :conversations + attr_reader :account, :unit, :inbox, :conversations, :period_start, :period_end def build_chunks - texts = conversations.map(&:to_llm_text).reject(&:blank?) + texts = conversations.map { |conversation| conversation_text(conversation) }.reject(&:blank?) return [] if texts.empty? chunks = [] @@ -48,6 +50,38 @@ class Captain::Llm::ConversationInsightService < Llm::BaseAiService chunks end + def conversation_text(conversation) + return conversation.to_llm_text unless period_start && period_end + + messages = conversation.messages + .where(created_at: period_start.beginning_of_day..period_end.end_of_day) + .where.not(message_type: %i[activity template]) + .where(private: false) + .order(created_at: :asc) + + return nil if messages.empty? + + [ + "Conversation ID: ##{conversation.display_id}", + "Channel: #{conversation.inbox.channel.name}", + 'Message History:', + messages.map { |message| format_message(message) }.join + ].join("\n") + end + + def format_message(message) + sender = case message.sender_type + when 'User' + 'Support Agent' + when 'Contact' + 'User' + else + 'Bot' + end + + "#{sender}: #{message.content_for_llm}\n" + end + def analyze_chunk(chunk) response = instrument_llm_call(instrumentation_params) do chat diff --git a/spec/enterprise/jobs/captain/reports/generate_insights_job_spec.rb b/spec/enterprise/jobs/captain/reports/generate_insights_job_spec.rb new file mode 100644 index 000000000..5248b55c6 --- /dev/null +++ b/spec/enterprise/jobs/captain/reports/generate_insights_job_spec.rb @@ -0,0 +1,53 @@ +require 'rails_helper' + +RSpec.describe Captain::Reports::GenerateInsightsJob do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:period_start) { Date.new(2026, 5, 17) } + let(:period_end) { Date.new(2026, 5, 17) } + let(:payload) do + { + 'top_topics' => [], + 'ai_failures' => [], + 'faq_gaps' => [], + 'sentiment' => { 'positive_count' => 0, 'negative_count' => 0, 'neutral_count' => 0, 'summary' => '' }, + 'highlights' => { 'praises' => [], 'complaints' => [] }, + 'most_requested_suites' => [], + 'price_reactions' => { 'summary' => '', 'objections_count' => 0 }, + 'customer_opportunities' => [], + 'recommendations' => [], + 'period_summary' => 'Resumo do dia.' + } + end + + it 'analyzes conversations with messages in the requested period and counts only period messages' do + conversation_with_today_messages = create(:conversation, account: account, inbox: inbox, created_at: 3.days.ago) + create(:message, account: account, inbox: inbox, conversation: conversation_with_today_messages, content: 'mensagem antiga', + created_at: period_start.prev_day.noon) + create(:message, account: account, inbox: inbox, conversation: conversation_with_today_messages, content: 'mensagem hoje', + created_at: period_start.noon) + create(:message, account: account, inbox: inbox, conversation: conversation_with_today_messages, content: 'resposta hoje', + message_type: 'outgoing', created_at: period_start.noon + 5.minutes) + + conversation_without_today_messages = create(:conversation, account: account, inbox: inbox, created_at: period_start.noon) + create(:message, account: account, inbox: inbox, conversation: conversation_without_today_messages, content: 'fora do periodo', + created_at: period_start.prev_day.noon) + + service = instance_double(Captain::Llm::ConversationInsightService, analyze: payload) + expect(Captain::Llm::ConversationInsightService).to receive(:new) + .with(hash_including(conversations: [conversation_with_today_messages], period_start: period_start, period_end: period_end)) + .and_return(service) + + described_class.perform_now(account.id, nil, period_start, period_end, inbox.id) + + insight = Captain::ConversationInsight.find_by!( + account_id: account.id, + inbox_id: inbox.id, + period_start: period_start, + period_end: period_end + ) + expect(insight.conversations_count).to eq(1) + expect(insight.messages_count).to eq(2) + expect(insight).to be_done + end +end diff --git a/spec/enterprise/services/captain/llm/conversation_insight_service_spec.rb b/spec/enterprise/services/captain/llm/conversation_insight_service_spec.rb new file mode 100644 index 000000000..b23189b7a --- /dev/null +++ b/spec/enterprise/services/captain/llm/conversation_insight_service_spec.rb @@ -0,0 +1,30 @@ +require 'rails_helper' + +RSpec.describe Captain::Llm::ConversationInsightService do + describe '#build_chunks' do + it 'formats only messages inside the requested period' do + account = create(:account) + inbox = create(:inbox, account: account) + period_start = Date.new(2026, 5, 17) + conversation = create(:conversation, account: account, inbox: inbox) + + create(:message, account: account, inbox: inbox, conversation: conversation, content: 'mensagem antiga', + created_at: period_start.prev_day.noon) + create(:message, account: account, inbox: inbox, conversation: conversation, content: 'mensagem do periodo', + created_at: period_start.noon) + + service = described_class.new( + account: account, + inbox: inbox, + conversations: [conversation], + period_start: period_start, + period_end: period_start + ) + + text = service.send(:build_chunks).join("\n") + + expect(text).to include('mensagem do periodo') + expect(text).not_to include('mensagem antiga') + end + end +end