feat(captain): add AI reports page with insights generation

Implementa a página Relatórios IA com geração de análises semanais
por IA baseadas nas conversas de cada unidade/caixa de entrada.

Funcionalidades:
- Página /settings/captain/reports com dois tabs (Insights IA / Operacional)
- Botão "Gerar Análise" que enfileira job Sidekiq
- Filtro por unidade ou caixa de entrada
- Exibe insights com status (pendente/processando/concluído/falhou)
- Mostra top_topics, ai_failures e period_summary
- Estado vazio com CTA para gerar primeiro relatório

Backend:
- InsightsController com endpoints index/show/generate
- GenerateInsightsJob que processa conversas com LLM
- ConversationInsightService com chunking e merge inteligente
- Migração para adicionar inbox_id à tabela captain_conversation_insights
- Link sidebar "Relatórios IA" em /settings/captain/reports

Frontend:
- Vuex store captainReports com actions/mutations/getters
- API client CaptainReportsAPI (getInsights, generateInsight)
- i18n en e pt_BR para CAPTAIN_REPORTS.*

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rodrigo Borba 2026-02-27 07:05:58 -03:00
parent 972fc5c67b
commit 87bff8126c
13 changed files with 577 additions and 100 deletions

View File

@ -1,13 +1,19 @@
class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Accounts::BaseController
# rubocop:disable Metrics/AbcSize
def index
unit_id = params[:unit_id].present? ? params[:unit_id].to_i : nil
insights = Captain::ConversationInsight
.where(account_id: Current.account.id, captain_unit_id: unit_id)
.order(period_start: :desc)
.limit(12)
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
scope = scope.where(captain_unit_id: nil, inbox_id: nil) if !unit_id && !inbox_id
insights = scope.order(period_start: :desc).limit(12)
render json: insights.map { |i| format_insight(i) }
end
# rubocop:enable Metrics/AbcSize
def show
insight = Captain::ConversationInsight.find_by!(
@ -19,35 +25,47 @@ class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Account
render json: { error: 'Insight não encontrado' }, status: :not_found
end
# rubocop:disable Metrics/AbcSize
def generate
period_start = parse_date(params[:period_start], Time.zone.today.beginning_of_week - 1.week)
period_end = parse_date(params[:period_end], Time.zone.today.beginning_of_week - 1.day)
unit_id = params[:unit_id].present? ? params[:unit_id].to_i : nil
period_end = parse_date(params[:period_end], Time.zone.today.beginning_of_week - 1.day)
unit_id = params[:unit_id].present? ? params[:unit_id].to_i : nil
inbox_id = params[:inbox_id].present? ? params[:inbox_id].to_i : nil
enqueue_insight(unit_id, period_start, period_end)
enqueue_insight(unit_id, inbox_id, period_start, period_end)
end
# rubocop:enable Metrics/AbcSize
private
def enqueue_insight(unit_id, period_start, period_end)
insight = Captain::ConversationInsight.find_or_initialize_by(
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?
reset_and_enqueue!(insight, unit_id, inbox_id, period_start, period_end)
end
def find_or_init_insight(unit_id, inbox_id, period_start, period_end)
Captain::ConversationInsight.find_or_initialize_by(
account_id: Current.account.id,
captain_unit_id: unit_id,
inbox_id: inbox_id,
period_start: period_start,
period_end: period_end
)
end
return render json: { status: 'processing', message: 'Análise já está em andamento' } if insight.processing?
def reset_and_enqueue!(insight, unit_id, inbox_id, period_start, period_end)
insight.status = 'pending'
insight.payload = nil
insight.save!
Captain::Reports::GenerateInsightsJob.perform_later(
Current.account.id, unit_id, period_start, period_end
Current.account.id, unit_id, period_start, period_end, inbox_id
)
render json: { status: 'queued', insight_id: insight.id }, status: :accepted
rescue StandardError => e
Rails.logger.error "Error generating insight: #{e.message}\n#{e.backtrace.join("\n")}"
render json: { error: "Erro ao enfileirar análise: #{e.message}" }, status: :internal_server_error
end
def parse_date(param, default)
@ -60,6 +78,7 @@ class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Account
{
id: insight.id,
unit_id: insight.captain_unit_id,
inbox_id: insight.inbox_id,
period_start: insight.period_start,
period_end: insight.period_end,
status: insight.status,

View File

@ -0,0 +1,26 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainReportsAPI extends ApiClient {
constructor() {
super('captain/reports', { accountScoped: true });
}
getOperational(params = {}) {
return axios.get(`${this.url}/operational`, { params });
}
getInsights(params = {}) {
return axios.get(`${this.url}/insights`, { params });
}
getInsight(id) {
return axios.get(`${this.url}/insights/${id}`);
}
generateInsight(data) {
return axios.post(`${this.url}/insights/generate`, data);
}
}
export default new CaptainReportsAPI();

View File

@ -408,6 +408,12 @@ const menuItems = computed(() => {
activeOn: ['captain_reservations_index'],
to: accountScopedRoute('captain_reservations_index'),
},
{
name: 'Reports',
label: t('SIDEBAR.CAPTAIN_REPORTS'),
activeOn: ['captain_settings_reports'],
to: accountScopedRoute('captain_settings_reports'),
},
],
},
{

View File

@ -1021,5 +1021,43 @@
"CONFIRM_BUTTON_LABEL": "Delete",
"CANCEL_BUTTON_LABEL": "Cancel"
}
},
"CAPTAIN_REPORTS": {
"TITLE": "AI Reports",
"DESC": "Weekly AI-generated insights based on conversations from each unit.",
"LOADING": "Loading reports...",
"ALL_UNITS": "All units",
"TABS": {
"INSIGHTS": "AI Insights",
"OPERATIONAL": "Operational"
},
"STATUS": {
"PENDING": "Pending",
"PROCESSING": "Processing",
"DONE": "Completed",
"FAILED": "Failed"
},
"GENERATE": {
"BUTTON": "Generate Analysis",
"SUCCESS": "Analysis queued successfully! It will be available shortly.",
"ERROR": "Error requesting analysis. Please try again."
},
"INSIGHT": {
"CONVERSATIONS": "conversations",
"MESSAGES": "messages",
"TOP_TOPICS": "Top Topics",
"AI_FAILURES": "AI Improvement Points",
"COUNT_PREFIX": "(",
"COUNT_SUFFIX": ")",
"BULLET": "•"
},
"EMPTY": {
"TITLE": "No analysis available",
"MESSAGE": "Click Generate Analysis to create the first weekly report with conversation insights."
},
"OPERATIONAL": {
"COMING_SOON": "Coming Soon",
"COMING_SOON_DESC": "Real-time operational data (reservations, Pix charges, etc.) will be available here soon."
}
}
}

View File

@ -342,6 +342,7 @@
"CAPTAIN_PIX_UNITS": "Unidades Pix",
"CAPTAIN_GALLERY": "Galeria",
"CAPTAIN_RESERVATIONS": "Reservas",
"CAPTAIN_REPORTS": "Relatórios IA",
"HOME": "Principal",
"AGENTS": "Agentes",
"AGENT_BOTS": "Robôs",
@ -817,5 +818,45 @@
"CONFIRM_BUTTON_LABEL": "Excluir",
"CANCEL_BUTTON_LABEL": "Cancelar"
}
},
"CAPTAIN_REPORTS": {
"TITLE": "Relatórios IA",
"DESC": "Análises semanais geradas por IA com base nas conversas de cada unidade.",
"LOADING": "Carregando relatórios...",
"ALL_UNITS": "Todas as unidades",
"TABS": {
"INSIGHTS": "Insights IA",
"OPERATIONAL": "Operacional"
},
"UNITS_GROUP": "Unidades Pix",
"INBOXES_GROUP": "Caixas de Entrada",
"STATUS": {
"PENDING": "Pendente",
"PROCESSING": "Processando",
"DONE": "Concluído",
"FAILED": "Falhou"
},
"GENERATE": {
"BUTTON": "Gerar Análise",
"SUCCESS": "Análise enfileirada com sucesso! Em breve estará disponível.",
"ERROR": "Erro ao solicitar análise. Tente novamente."
},
"INSIGHT": {
"CONVERSATIONS": "conversas",
"MESSAGES": "mensagens",
"TOP_TOPICS": "Principais Tópicos",
"AI_FAILURES": "Pontos de Melhoria da IA",
"COUNT_PREFIX": "(",
"COUNT_SUFFIX": ")",
"BULLET": "•"
},
"EMPTY": {
"TITLE": "Nenhuma análise disponível",
"MESSAGE": "Clique em Gerar Análise para criar o primeiro relatório semanal com insights das conversas."
},
"OPERATIONAL": {
"COMING_SOON": "Em breve",
"COMING_SOON_DESC": "Os dados operacionais em tempo real (reservas, cobranças Pix, etc.) estarão disponíveis aqui em breve."
}
}
}

View File

@ -7,6 +7,7 @@ import UnitsIndex from './units/Index.vue';
import UnitEdit from './units/Edit.vue';
import GalleryIndex from './gallery/Index.vue';
import GalleryEdit from './gallery/Edit.vue';
const ReportsIndex = () => import('./reports/Index.vue');
export default {
routes: [
@ -68,6 +69,14 @@ export default {
permissions: ['administrator'],
},
},
{
path: 'reports',
name: 'captain_settings_reports',
component: ReportsIndex,
meta: {
permissions: ['administrator'],
},
},
],
},
],

View File

@ -0,0 +1,299 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import SettingsLayout from '../../SettingsLayout.vue';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const { t } = useI18n();
const store = useStore();
const units = useMapGetter('captainUnits/getUnits');
const inboxes = useMapGetter('inboxes/getInboxes');
const insights = useMapGetter('captainReports/getInsights');
const uiFlags = useMapGetter('captainReports/getUIFlags');
const activeTab = ref('insights');
const selectedFilter = ref({ type: 'unit', id: null });
const tabs = [{ key: 'insights' }, { key: 'operational' }];
onMounted(async () => {
await store.dispatch('captainUnits/get');
await store.dispatch('inboxes/get');
await store.dispatch('captainReports/fetchInsights', {
unit_id: null,
});
});
const onFilterChange = async event => {
const value = event.target.value;
if (!value) {
selectedFilter.value = { type: 'unit', id: null };
} else {
const [type, id] = value.split(':');
selectedFilter.value = { type, id: Number(id) };
}
const params = {};
if (selectedFilter.value.type === 'unit') {
params.unit_id = selectedFilter.value.id;
} else {
params.inbox_id = selectedFilter.value.id;
}
await store.dispatch('captainReports/fetchInsights', params);
};
const onGenerateInsight = async () => {
const params = {};
if (selectedFilter.value.type === 'unit') {
params.unit_id = selectedFilter.value.id;
} else {
params.inbox_id = selectedFilter.value.id;
}
try {
await store.dispatch('captainReports/generateInsight', params);
useAlert(t('CAPTAIN_REPORTS.GENERATE.SUCCESS'));
} catch {
useAlert(t('CAPTAIN_REPORTS.GENERATE.ERROR'));
}
};
const statusLabel = status => {
const map = {
pending: t('CAPTAIN_REPORTS.STATUS.PENDING'),
processing: t('CAPTAIN_REPORTS.STATUS.PROCESSING'),
done: t('CAPTAIN_REPORTS.STATUS.DONE'),
failed: t('CAPTAIN_REPORTS.STATUS.FAILED'),
};
return map[status] || status;
};
const statusClass = status => {
const map = {
pending: 'bg-n-amber-2 text-n-amber-11',
processing: 'bg-n-blue-2 text-n-blue-11',
done: 'bg-n-teal-2 text-n-teal-11',
failed: 'bg-n-ruby-2 text-n-ruby-11',
};
return map[status] || 'bg-n-slate-3 text-n-slate-11';
};
const formatDate = dateStr => {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('pt-BR');
};
const periodLabel = insight =>
`${formatDate(insight.period_start)} ${formatDate(insight.period_end)}`;
</script>
<template>
<SettingsLayout
:is-loading="uiFlags.isFetchingInsights"
:loading-message="t('CAPTAIN_REPORTS.LOADING')"
>
<template #header>
<BaseSettingsHeader
:title="t('CAPTAIN_REPORTS.TITLE')"
:description="t('CAPTAIN_REPORTS.DESC')"
>
<template #actions>
<div class="flex items-center gap-3">
<select
class="rounded-lg border border-n-weak bg-n-alpha-1 px-3 py-2 text-sm text-n-slate-12 focus:outline-none focus:ring-2 focus:ring-n-brand"
@change="onFilterChange"
>
<option value="">{{ t('CAPTAIN_REPORTS.ALL_UNITS') }}</option>
<optgroup :label="t('CAPTAIN_REPORTS.UNITS_GROUP')">
<option
v-for="unit in units"
:key="unit.id"
:value="`unit:${unit.id}`"
>
{{ unit.name }}
</option>
</optgroup>
<optgroup :label="t('CAPTAIN_REPORTS.INBOXES_GROUP')">
<option
v-for="inbox in inboxes"
:key="inbox.id"
:value="`inbox:${inbox.id}`"
>
{{ inbox.name }}
</option>
</optgroup>
</select>
<Button
:label="t('CAPTAIN_REPORTS.GENERATE.BUTTON')"
icon="i-lucide-sparkles"
:is-loading="uiFlags.isGenerating"
@click="onGenerateInsight"
/>
</div>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<div class="flex flex-col px-6 pb-8">
<!-- Tabs -->
<div class="mb-6 flex gap-1 border-b border-n-weak">
<button
v-for="tab in tabs"
:key="tab.key"
class="px-4 py-2 text-sm font-medium transition-colors"
:class="
activeTab === tab.key
? 'border-b-2 border-n-brand text-n-brand'
: 'text-n-slate-10 hover:text-n-slate-12'
"
@click="activeTab = tab.key"
>
{{
tab.key === 'insights'
? t('CAPTAIN_REPORTS.TABS.INSIGHTS')
: t('CAPTAIN_REPORTS.TABS.OPERATIONAL')
}}
</button>
</div>
<!-- Tab: Insights de IA -->
<div v-if="activeTab === 'insights'">
<div v-if="insights && insights.length > 0" class="space-y-3">
<div
v-for="insight in insights"
:key="insight.id"
class="rounded-xl border border-n-weak bg-n-alpha-1 p-4"
>
<div class="flex items-start justify-between">
<div>
<p class="mb-1 text-sm font-semibold text-n-slate-12">
{{ periodLabel(insight) }}
</p>
<p class="mb-2 text-xs text-n-slate-10">
{{ insight.conversations_count }}
{{ t('CAPTAIN_REPORTS.INSIGHT.CONVERSATIONS') }}
{{ insight.messages_count }}
{{ t('CAPTAIN_REPORTS.INSIGHT.MESSAGES') }}
</p>
</div>
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
:class="statusClass(insight.status)"
>
{{ statusLabel(insight.status) }}
</span>
</div>
<!-- top_topics -->
<div
v-if="insight.payload && insight.payload.top_topics?.length"
class="mt-3"
>
<p class="mb-2 text-xs font-semibold uppercase text-n-slate-9">
{{ t('CAPTAIN_REPORTS.INSIGHT.TOP_TOPICS') }}
</p>
<div class="flex flex-wrap gap-2">
<span
v-for="topic in insight.payload.top_topics.slice(0, 5)"
:key="topic.topic"
class="inline-flex items-center gap-1 rounded-full bg-n-blue-2 px-2 py-1 text-xs text-n-blue-11"
>
{{ topic.topic }}
{{ t('CAPTAIN_REPORTS.INSIGHT.COUNT_PREFIX') }}
{{ topic.count }}
{{ t('CAPTAIN_REPORTS.INSIGHT.COUNT_SUFFIX') }}
</span>
</div>
</div>
<!-- ai_failures -->
<div
v-if="insight.payload && insight.payload.ai_failures?.length"
class="mt-3"
>
<p class="mb-2 text-xs font-semibold uppercase text-n-slate-9">
{{ t('CAPTAIN_REPORTS.INSIGHT.AI_FAILURES') }}
</p>
<ul class="space-y-1">
<li
v-for="(failure, idx) in insight.payload.ai_failures.slice(
0,
3
)"
:key="idx"
class="text-xs text-n-slate-11"
>
{{ t('CAPTAIN_REPORTS.INSIGHT.BULLET') }}
{{ failure.description }}
</li>
</ul>
</div>
<!-- period_summary -->
<div
v-if="insight.payload && insight.payload.period_summary"
class="mt-3 rounded-lg bg-n-alpha-2 p-3"
>
<p class="mb-0 text-xs italic text-n-slate-11">
{{ insight.payload.period_summary }}
</p>
</div>
</div>
</div>
<!-- Empty State -->
<div
v-else
class="flex flex-col items-center justify-center gap-4 py-20 text-center"
>
<div
class="flex size-16 items-center justify-center rounded-full bg-n-blue-2"
>
<span class="i-lucide-bar-chart-2 size-8 text-n-blue-9" />
</div>
<div class="flex flex-col gap-1">
<p class="mb-0 text-base font-medium text-n-slate-12">
{{ t('CAPTAIN_REPORTS.EMPTY.TITLE') }}
</p>
<p class="mb-0 max-w-sm text-sm text-n-slate-10">
{{ t('CAPTAIN_REPORTS.EMPTY.MESSAGE') }}
</p>
</div>
<Button
:label="t('CAPTAIN_REPORTS.GENERATE.BUTTON')"
icon="i-lucide-sparkles"
:is-loading="uiFlags.isGenerating"
@click="onGenerateInsight"
/>
</div>
</div>
<!-- Tab: Operacional -->
<div v-else-if="activeTab === 'operational'">
<div
class="flex flex-col items-center justify-center gap-4 py-20 text-center"
>
<div
class="flex size-16 items-center justify-center rounded-full bg-n-amber-2"
>
<span class="i-lucide-construction size-8 text-n-amber-9" />
</div>
<p class="mb-0 text-base font-medium text-n-slate-12">
{{ t('CAPTAIN_REPORTS.OPERATIONAL.COMING_SOON') }}
</p>
<p class="mb-0 max-w-sm text-sm text-n-slate-10">
{{ t('CAPTAIN_REPORTS.OPERATIONAL.COMING_SOON_DESC') }}
</p>
</div>
</div>
</div>
</template>
</SettingsLayout>
</template>

View File

@ -1,36 +1,5 @@
import * as MutationTypes from '../mutation-types';
import ApiClient from '../../api';
const captainReportsAPI = {
getOperational: (accountId, params) =>
ApiClient.get(`/api/v1/accounts/${accountId}/captain/reports/operational`, {
params,
}),
getInsights: (accountId, params) =>
ApiClient.get(`/api/v1/accounts/${accountId}/captain/reports/insights`, {
params,
}),
getInsight: (accountId, id) =>
ApiClient.get(
`/api/v1/accounts/${accountId}/captain/reports/insights/${id}`
),
generateInsight: (accountId, data) =>
ApiClient.post(
`/api/v1/accounts/${accountId}/captain/reports/insights/generate`,
data
),
};
const state = {
operational: null,
insights: [],
currentInsight: null,
uiFlags: {
isFetchingOperational: false,
isFetchingInsights: false,
isGenerating: false,
},
};
import CaptainReportsAPI from '../../api/captain/reports';
export const getters = {
getOperational: $state => $state.operational,
@ -55,16 +24,12 @@ export const mutations = {
};
export const actions = {
async fetchOperational({ commit, rootGetters }, params = {}) {
const accountId = rootGetters['auth/getCurrentAccountId'];
async fetchOperational({ commit }, params = {}) {
commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, {
isFetchingOperational: true,
});
try {
const { data } = await captainReportsAPI.getOperational(
accountId,
params
);
const { data } = await CaptainReportsAPI.getOperational(params);
commit(MutationTypes.SET_CAPTAIN_REPORTS_OPERATIONAL, data);
} finally {
commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, {
@ -73,13 +38,12 @@ export const actions = {
}
},
async fetchInsights({ commit, rootGetters }, params = {}) {
const accountId = rootGetters['auth/getCurrentAccountId'];
async fetchInsights({ commit }, params = {}) {
commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, {
isFetchingInsights: true,
});
try {
const { data } = await captainReportsAPI.getInsights(accountId, params);
const { data } = await CaptainReportsAPI.getInsights(params);
commit(MutationTypes.SET_CAPTAIN_REPORTS_INSIGHTS, data);
} finally {
commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, {
@ -88,12 +52,11 @@ export const actions = {
}
},
async generateInsight({ commit, dispatch, rootGetters }, payload) {
const accountId = rootGetters['auth/getCurrentAccountId'];
async generateInsight({ commit, dispatch }, params) {
commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, { isGenerating: true });
try {
await captainReportsAPI.generateInsight(accountId, payload);
await dispatch('fetchInsights', { unit_id: payload.unit_id });
await CaptainReportsAPI.generateInsight(params);
await dispatch('fetchInsights', params);
} finally {
commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, {
isGenerating: false,
@ -102,6 +65,17 @@ export const actions = {
},
};
const state = {
operational: null,
insights: [],
currentInsight: null,
uiFlags: {
isFetchingOperational: false,
isFetchingInsights: false,
isGenerating: false,
},
};
export default {
namespaced: true,
state,

View File

@ -0,0 +1,26 @@
class AddInboxIdToCaptainConversationInsights < ActiveRecord::Migration[7.1]
def up
add_reference :captain_conversation_insights, :inbox, foreign_key: true, null: true
# Remove índice antigo que causaria conflito com o novo índice composto
remove_index :captain_conversation_insights, name: 'idx_captain_insights_unique_period'
# Novo índice que permite análise por Unidade OU por Inbox
add_index :captain_conversation_insights,
%i[captain_unit_id inbox_id period_start period_end],
unique: true,
name: 'idx_captain_insights_on_unit_inbox_period'
end
def down
remove_index :captain_conversation_insights, name: 'idx_captain_insights_on_unit_inbox_period'
# Recria o índice original para tornar o rollback completo
add_index :captain_conversation_insights,
%i[captain_unit_id period_start period_end],
unique: true,
name: 'idx_captain_insights_unique_period'
remove_reference :captain_conversation_insights, :inbox, foreign_key: true
end
end

View File

@ -1,18 +1,20 @@
class Captain::Reports::GenerateInsightsJob < ApplicationJob
queue_as :default
# Gera insights de IA para uma unidade específica em um período.
# Gera insights de IA para uma unidade ou inbox específica em um período.
# Pode ser disparado on-demand (botão na UI) ou pelo WeeklyInsightsJob.
def perform(account_id, unit_id, period_start, period_end)
def perform(account_id, unit_id, period_start, period_end, inbox_id = nil)
account = Account.find_by(id: account_id)
return unless account
unit = account.captain_units.find_by(id: unit_id)
insight = find_or_create_insight(account_id, unit_id, period_start, period_end)
unit = account.captain_units.find_by(id: unit_id) if unit_id
inbox = account.inboxes.find_by(id: inbox_id) if inbox_id
insight = find_or_create_insight(account_id, unit_id, inbox_id, period_start, period_end)
return if insight.processing? || insight.done?
insight.mark_processing!
run_analysis(account, unit, insight, period_start, period_end)
run_analysis(account, unit, inbox, insight, period_start, period_end)
rescue StandardError => e
Rails.logger.error "[Captain::Reports::GenerateInsightsJob] Error: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}"
insight&.mark_failed!
@ -20,10 +22,11 @@ class Captain::Reports::GenerateInsightsJob < ApplicationJob
private
def find_or_create_insight(account_id, unit_id, period_start, period_end)
def find_or_create_insight(account_id, unit_id, inbox_id, period_start, period_end)
insight = Captain::ConversationInsight.find_or_initialize_by(
account_id: account_id,
captain_unit_id: unit_id,
inbox_id: inbox_id,
period_start: period_start,
period_end: period_end
)
@ -31,13 +34,14 @@ class Captain::Reports::GenerateInsightsJob < ApplicationJob
insight
end
def run_analysis(account, unit, insight, period_start, period_end)
conversations = fetch_conversations(account, unit, period_start, period_end)
def run_analysis(account, unit, inbox, insight, period_start, period_end)
conversations = fetch_conversations(account, unit, inbox, period_start, period_end)
insight.update!(conversations_count: conversations.count)
payload = Captain::Llm::ConversationInsightService.new(
account: account,
unit: unit,
inbox: inbox,
conversations: conversations
).analyze
@ -45,12 +49,14 @@ class Captain::Reports::GenerateInsightsJob < ApplicationJob
insight.mark_done!(payload)
end
def fetch_conversations(account, unit, period_start, period_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)
if unit
if inbox
scope = scope.where(inbox_id: inbox.id)
elsif unit
inbox_ids = unit.inboxes.pluck(:id)
scope = scope.where(inbox_id: inbox_ids) if inbox_ids.any?
end

View File

@ -24,12 +24,14 @@ class Captain::ConversationInsight < ApplicationRecord
belongs_to :account
belongs_to :captain_unit, class_name: 'Captain::Unit', optional: true
belongs_to :inbox, optional: true
validates :period_start, :period_end, :status, presence: true
validates :status, inclusion: { in: STATUSES }
scope :done, -> { where(status: 'done') }
scope :for_unit, ->(unit_id) { where(captain_unit_id: unit_id) }
scope :for_inbox, ->(inbox_id) { where(inbox_id: inbox_id) }
scope :for_period, ->(start_date, end_date) { where(period_start: start_date, period_end: end_date) }
def mark_processing!

View File

@ -3,10 +3,11 @@ class Captain::Llm::ConversationInsightService < Llm::BaseAiService
MAX_CHARS_PER_CHUNK = 40_000
def initialize(account:, unit:, conversations:)
def initialize(account:, conversations:, unit: nil, inbox: nil)
super()
@account = account
@unit = unit
@inbox = inbox
@conversations = conversations
end
@ -23,7 +24,7 @@ class Captain::Llm::ConversationInsightService < Llm::BaseAiService
private
attr_reader :account, :unit, :conversations
attr_reader :account, :unit, :inbox, :conversations
def build_chunks
texts = conversations.map(&:to_llm_text).reject(&:blank?)
@ -62,8 +63,9 @@ class Captain::Llm::ConversationInsightService < Llm::BaseAiService
end
def system_prompt
entity_name = inbox&.name || unit&.name || 'Geral'
Captain::Llm::SystemPromptsService.conversation_insights_analyzer(
unit.name,
entity_name,
account.locale_english_name
)
end

View File

@ -18,17 +18,19 @@ class Captain::Tools::SendSuiteImagesTool < Captain::Tools::BaseTool
properties: {
suite_category: {
type: 'string',
description: 'Opcional. Categoria/tipo da suíte (ex: Hidromassagem, ALEXA, STILO, VL, PrimeAL). ' \
'Use quando o cliente mencionar o TIPO/NOME da suíte, não o número.'
description: 'Categoria/tipo da suíte (ex: Hidromassagem, ALEXA, STILO). ' \
'Use SOMENTE quando o cliente mencionar o TIPO/NOME da suíte sem citar um número específico. ' \
'Não combine com suite_number — os parâmetros são mutuamente exclusivos.'
},
suite_number: {
type: 'string',
description: 'Opcional. Número específico da suíte (ex: 101, 102, 103, 109, 202). ' \
'Use APENAS quando o cliente mencionar um NÚMERO específico como "suíte 101" ou "quarto 202".'
description: 'Número específico da suíte (ex: 101, 202, 109). ' \
'Use quando o cliente mencionar um NÚMERO como "suíte 101". ' \
'Quando fornecido, IGNORA suite_category. Não combine com suite_category.'
},
limit: {
type: 'integer',
description: 'Opcional. Quantidade de imagens para enviar (padrão: 3, máximo: 5).'
description: 'Quantidade de imagens para enviar (padrão: 3, máximo: 5).'
},
inbox_id: {
type: 'integer',
@ -150,27 +152,45 @@ class Captain::Tools::SendSuiteImagesTool < Captain::Tools::BaseTool
hash.key?('conversation')
end
# rubocop:disable Metrics/MethodLength
# Lógica de busca mutuamente exclusiva:
# - Suite number fornecido → busca SOMENTE por número (ignora categoria)
# - Só categoria fornecida → busca SOMENTE por categoria
def find_items(actual_params)
scope = Captain::GalleryItem
.active
.where(account_id: @conversation.account_id)
.includes(image_attachment: :blob)
.ordered
suite_number = normalize_text_search(actual_params[:suite_number])
category = normalize_text_search(actual_params[:suite_category])
unit_id = actual_params[:captain_unit_id].presence
scope = scope.where(captain_unit_id: unit_id) if unit_id.present?
base_scope = Captain::GalleryItem
.active
.where(account_id: @conversation.account_id)
.includes(image_attachment: :blob)
.ordered
category = normalize_filter(actual_params[:suite_category])
suite_number = normalize_filter(actual_params[:suite_number])
if suite_number.present?
# Prioridade: número da suíte (match exato normalizado)
filters = base_scope.where('LOWER(suite_number) = ?', suite_number)
elsif category.present?
# Categoria: fuzzy case-insensitive, ignora acentos via REPLACE
filters = base_scope.where(
'LOWER(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(suite_category, ' \
"'ã','a'),'â','a'),'á','a'),'à','a'),'é','e'),'ê','e')) " \
'= LOWER(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(?, ' \
"'ã','a'),'â','a'),'á','a'),'à','a'),'é','e'),'ê','e'))",
category
)
else
return Captain::GalleryItem.none
end
scope = scope.where('LOWER(suite_category) = ?', category.downcase) if category.present?
scope = scope.where('LOWER(suite_number) = ?', suite_number.downcase) if suite_number.present?
# Tenta primeiro o inbox atual da conversa (jamais busca em outros inboxes)
target_inbox = resolve_target_inbox_id(actual_params)
inbox_result = filters.where(scope: 'inbox', inbox_id: target_inbox)
return inbox_result if inbox_result.exists?
inbox_scope = scope.where(scope: 'inbox', inbox_id: resolve_target_inbox_id(actual_params))
return inbox_scope if inbox_scope.exists?
scope.where(scope: 'global')
# Fallback APENAS para acervo global (fotos genéricas sem vínculo de unidade)
filters.where(scope: 'global')
end
# rubocop:enable Metrics/MethodLength
def find_selected_items(actual_params)
items = find_items(actual_params)
@ -202,6 +222,12 @@ class Captain::Tools::SendSuiteImagesTool < Captain::Tools::BaseTool
value.to_s.strip.presence
end
# Normaliza para comparação SQL: strip + downcase
def normalize_text_search(value)
str = value.to_s.strip.downcase
str.presence
end
def resolve_target_inbox_id(actual_params)
requested_inbox_id = actual_params[:inbox_id].presence
return @conversation.inbox_id if requested_inbox_id.blank?
@ -210,23 +236,26 @@ class Captain::Tools::SendSuiteImagesTool < Captain::Tools::BaseTool
end
def no_images_response(actual_params)
category = normalize_filter(actual_params[:suite_category])
category = normalize_filter(actual_params[:suite_category])
suite_number = normalize_filter(actual_params[:suite_number])
# Sugerir buscar por categoria se buscou por número e não achou
# Se buscou por número e não achou, sugerir tentar pela categoria da suíte
suggestion = if category.blank? && suite_number.present?
"\n\nDica para a IA: Tente buscar por suite_category em vez de suite_number."
' Dica: tente usar suite_category para buscar fotos da categoria desta suíte.'
else
''
end
detail = []
detail << "categoria #{category}" if category.present?
detail << "suíte #{suite_number}" if suite_number.present?
detail_text = detail.present? ? " para #{detail.join(' e ')}" : ''
searched_for = if suite_number.present?
"suíte #{suite_number}"
elsif category.present?
"categoria #{category}"
else
'as fotos solicitadas'
end
success_response(
"Não encontrei fotos cadastradas na galeria desta caixa de entrada nem no acervo global#{detail_text}.#{suggestion}"
"Não encontrei fotos para #{searched_for} na galeria (nem por inbox nem no acervo global).#{suggestion}"
)
end