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:
parent
972fc5c67b
commit
87bff8126c
@ -1,13 +1,19 @@
|
|||||||
class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Accounts::BaseController
|
||||||
|
# rubocop:disable Metrics/AbcSize
|
||||||
def index
|
def index
|
||||||
unit_id = params[:unit_id].present? ? params[:unit_id].to_i : nil
|
unit_id = params[:unit_id].present? ? params[:unit_id].to_i : nil
|
||||||
insights = Captain::ConversationInsight
|
inbox_id = params[:inbox_id].present? ? params[:inbox_id].to_i : nil
|
||||||
.where(account_id: Current.account.id, captain_unit_id: unit_id)
|
|
||||||
.order(period_start: :desc)
|
scope = Captain::ConversationInsight.where(account_id: Current.account.id)
|
||||||
.limit(12)
|
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) }
|
render json: insights.map { |i| format_insight(i) }
|
||||||
end
|
end
|
||||||
|
# rubocop:enable Metrics/AbcSize
|
||||||
|
|
||||||
def show
|
def show
|
||||||
insight = Captain::ConversationInsight.find_by!(
|
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
|
render json: { error: 'Insight não encontrado' }, status: :not_found
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# rubocop:disable Metrics/AbcSize
|
||||||
def generate
|
def generate
|
||||||
period_start = parse_date(params[:period_start], Time.zone.today.beginning_of_week - 1.week)
|
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)
|
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
|
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
|
end
|
||||||
|
# rubocop:enable Metrics/AbcSize
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def enqueue_insight(unit_id, period_start, period_end)
|
def enqueue_insight(unit_id, inbox_id, period_start, period_end)
|
||||||
insight = Captain::ConversationInsight.find_or_initialize_by(
|
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,
|
account_id: Current.account.id,
|
||||||
captain_unit_id: unit_id,
|
captain_unit_id: unit_id,
|
||||||
|
inbox_id: inbox_id,
|
||||||
period_start: period_start,
|
period_start: period_start,
|
||||||
period_end: period_end
|
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.status = 'pending'
|
||||||
insight.payload = nil
|
insight.payload = nil
|
||||||
insight.save!
|
insight.save!
|
||||||
|
|
||||||
Captain::Reports::GenerateInsightsJob.perform_later(
|
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
|
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
|
end
|
||||||
|
|
||||||
def parse_date(param, default)
|
def parse_date(param, default)
|
||||||
@ -60,6 +78,7 @@ class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Account
|
|||||||
{
|
{
|
||||||
id: insight.id,
|
id: insight.id,
|
||||||
unit_id: insight.captain_unit_id,
|
unit_id: insight.captain_unit_id,
|
||||||
|
inbox_id: insight.inbox_id,
|
||||||
period_start: insight.period_start,
|
period_start: insight.period_start,
|
||||||
period_end: insight.period_end,
|
period_end: insight.period_end,
|
||||||
status: insight.status,
|
status: insight.status,
|
||||||
|
|||||||
26
app/javascript/dashboard/api/captain/reports.js
Normal file
26
app/javascript/dashboard/api/captain/reports.js
Normal 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();
|
||||||
@ -408,6 +408,12 @@ const menuItems = computed(() => {
|
|||||||
activeOn: ['captain_reservations_index'],
|
activeOn: ['captain_reservations_index'],
|
||||||
to: accountScopedRoute('captain_reservations_index'),
|
to: accountScopedRoute('captain_reservations_index'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Reports',
|
||||||
|
label: t('SIDEBAR.CAPTAIN_REPORTS'),
|
||||||
|
activeOn: ['captain_settings_reports'],
|
||||||
|
to: accountScopedRoute('captain_settings_reports'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1021,5 +1021,43 @@
|
|||||||
"CONFIRM_BUTTON_LABEL": "Delete",
|
"CONFIRM_BUTTON_LABEL": "Delete",
|
||||||
"CANCEL_BUTTON_LABEL": "Cancel"
|
"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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -342,6 +342,7 @@
|
|||||||
"CAPTAIN_PIX_UNITS": "Unidades Pix",
|
"CAPTAIN_PIX_UNITS": "Unidades Pix",
|
||||||
"CAPTAIN_GALLERY": "Galeria",
|
"CAPTAIN_GALLERY": "Galeria",
|
||||||
"CAPTAIN_RESERVATIONS": "Reservas",
|
"CAPTAIN_RESERVATIONS": "Reservas",
|
||||||
|
"CAPTAIN_REPORTS": "Relatórios IA",
|
||||||
"HOME": "Principal",
|
"HOME": "Principal",
|
||||||
"AGENTS": "Agentes",
|
"AGENTS": "Agentes",
|
||||||
"AGENT_BOTS": "Robôs",
|
"AGENT_BOTS": "Robôs",
|
||||||
@ -817,5 +818,45 @@
|
|||||||
"CONFIRM_BUTTON_LABEL": "Excluir",
|
"CONFIRM_BUTTON_LABEL": "Excluir",
|
||||||
"CANCEL_BUTTON_LABEL": "Cancelar"
|
"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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import UnitsIndex from './units/Index.vue';
|
|||||||
import UnitEdit from './units/Edit.vue';
|
import UnitEdit from './units/Edit.vue';
|
||||||
import GalleryIndex from './gallery/Index.vue';
|
import GalleryIndex from './gallery/Index.vue';
|
||||||
import GalleryEdit from './gallery/Edit.vue';
|
import GalleryEdit from './gallery/Edit.vue';
|
||||||
|
const ReportsIndex = () => import('./reports/Index.vue');
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
routes: [
|
routes: [
|
||||||
@ -68,6 +69,14 @@ export default {
|
|||||||
permissions: ['administrator'],
|
permissions: ['administrator'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'reports',
|
||||||
|
name: 'captain_settings_reports',
|
||||||
|
component: ReportsIndex,
|
||||||
|
meta: {
|
||||||
|
permissions: ['administrator'],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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>
|
||||||
@ -1,36 +1,5 @@
|
|||||||
import * as MutationTypes from '../mutation-types';
|
import * as MutationTypes from '../mutation-types';
|
||||||
import ApiClient from '../../api';
|
import CaptainReportsAPI from '../../api/captain/reports';
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getters = {
|
export const getters = {
|
||||||
getOperational: $state => $state.operational,
|
getOperational: $state => $state.operational,
|
||||||
@ -55,16 +24,12 @@ export const mutations = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
async fetchOperational({ commit, rootGetters }, params = {}) {
|
async fetchOperational({ commit }, params = {}) {
|
||||||
const accountId = rootGetters['auth/getCurrentAccountId'];
|
|
||||||
commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, {
|
commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, {
|
||||||
isFetchingOperational: true,
|
isFetchingOperational: true,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const { data } = await captainReportsAPI.getOperational(
|
const { data } = await CaptainReportsAPI.getOperational(params);
|
||||||
accountId,
|
|
||||||
params
|
|
||||||
);
|
|
||||||
commit(MutationTypes.SET_CAPTAIN_REPORTS_OPERATIONAL, data);
|
commit(MutationTypes.SET_CAPTAIN_REPORTS_OPERATIONAL, data);
|
||||||
} finally {
|
} finally {
|
||||||
commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, {
|
commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, {
|
||||||
@ -73,13 +38,12 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchInsights({ commit, rootGetters }, params = {}) {
|
async fetchInsights({ commit }, params = {}) {
|
||||||
const accountId = rootGetters['auth/getCurrentAccountId'];
|
|
||||||
commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, {
|
commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, {
|
||||||
isFetchingInsights: true,
|
isFetchingInsights: true,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const { data } = await captainReportsAPI.getInsights(accountId, params);
|
const { data } = await CaptainReportsAPI.getInsights(params);
|
||||||
commit(MutationTypes.SET_CAPTAIN_REPORTS_INSIGHTS, data);
|
commit(MutationTypes.SET_CAPTAIN_REPORTS_INSIGHTS, data);
|
||||||
} finally {
|
} finally {
|
||||||
commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, {
|
commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, {
|
||||||
@ -88,12 +52,11 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async generateInsight({ commit, dispatch, rootGetters }, payload) {
|
async generateInsight({ commit, dispatch }, params) {
|
||||||
const accountId = rootGetters['auth/getCurrentAccountId'];
|
|
||||||
commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, { isGenerating: true });
|
commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, { isGenerating: true });
|
||||||
try {
|
try {
|
||||||
await captainReportsAPI.generateInsight(accountId, payload);
|
await CaptainReportsAPI.generateInsight(params);
|
||||||
await dispatch('fetchInsights', { unit_id: payload.unit_id });
|
await dispatch('fetchInsights', params);
|
||||||
} finally {
|
} finally {
|
||||||
commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, {
|
commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, {
|
||||||
isGenerating: false,
|
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 {
|
export default {
|
||||||
namespaced: true,
|
namespaced: true,
|
||||||
state,
|
state,
|
||||||
|
|||||||
@ -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
|
||||||
@ -1,18 +1,20 @@
|
|||||||
class Captain::Reports::GenerateInsightsJob < ApplicationJob
|
class Captain::Reports::GenerateInsightsJob < ApplicationJob
|
||||||
queue_as :default
|
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.
|
# 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)
|
account = Account.find_by(id: account_id)
|
||||||
return unless account
|
return unless account
|
||||||
|
|
||||||
unit = account.captain_units.find_by(id: unit_id)
|
unit = account.captain_units.find_by(id: unit_id) if unit_id
|
||||||
insight = find_or_create_insight(account_id, unit_id, period_start, period_end)
|
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?
|
return if insight.processing? || insight.done?
|
||||||
|
|
||||||
insight.mark_processing!
|
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
|
rescue StandardError => e
|
||||||
Rails.logger.error "[Captain::Reports::GenerateInsightsJob] Error: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}"
|
Rails.logger.error "[Captain::Reports::GenerateInsightsJob] Error: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}"
|
||||||
insight&.mark_failed!
|
insight&.mark_failed!
|
||||||
@ -20,10 +22,11 @@ class Captain::Reports::GenerateInsightsJob < ApplicationJob
|
|||||||
|
|
||||||
private
|
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(
|
insight = Captain::ConversationInsight.find_or_initialize_by(
|
||||||
account_id: account_id,
|
account_id: account_id,
|
||||||
captain_unit_id: unit_id,
|
captain_unit_id: unit_id,
|
||||||
|
inbox_id: inbox_id,
|
||||||
period_start: period_start,
|
period_start: period_start,
|
||||||
period_end: period_end
|
period_end: period_end
|
||||||
)
|
)
|
||||||
@ -31,13 +34,14 @@ class Captain::Reports::GenerateInsightsJob < ApplicationJob
|
|||||||
insight
|
insight
|
||||||
end
|
end
|
||||||
|
|
||||||
def run_analysis(account, unit, insight, period_start, period_end)
|
def run_analysis(account, unit, inbox, insight, period_start, period_end)
|
||||||
conversations = fetch_conversations(account, unit, period_start, period_end)
|
conversations = fetch_conversations(account, unit, inbox, period_start, period_end)
|
||||||
insight.update!(conversations_count: conversations.count)
|
insight.update!(conversations_count: conversations.count)
|
||||||
|
|
||||||
payload = Captain::Llm::ConversationInsightService.new(
|
payload = Captain::Llm::ConversationInsightService.new(
|
||||||
account: account,
|
account: account,
|
||||||
unit: unit,
|
unit: unit,
|
||||||
|
inbox: inbox,
|
||||||
conversations: conversations
|
conversations: conversations
|
||||||
).analyze
|
).analyze
|
||||||
|
|
||||||
@ -45,12 +49,14 @@ class Captain::Reports::GenerateInsightsJob < ApplicationJob
|
|||||||
insight.mark_done!(payload)
|
insight.mark_done!(payload)
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_conversations(account, unit, period_start, period_end)
|
def fetch_conversations(account, unit, inbox, period_start, period_end)
|
||||||
scope = account.conversations
|
scope = account.conversations
|
||||||
.where(created_at: period_start.beginning_of_day..period_end.end_of_day)
|
.where(created_at: period_start.beginning_of_day..period_end.end_of_day)
|
||||||
.includes(:messages)
|
.includes(:messages)
|
||||||
|
|
||||||
if unit
|
if inbox
|
||||||
|
scope = scope.where(inbox_id: inbox.id)
|
||||||
|
elsif unit
|
||||||
inbox_ids = unit.inboxes.pluck(:id)
|
inbox_ids = unit.inboxes.pluck(:id)
|
||||||
scope = scope.where(inbox_id: inbox_ids) if inbox_ids.any?
|
scope = scope.where(inbox_id: inbox_ids) if inbox_ids.any?
|
||||||
end
|
end
|
||||||
|
|||||||
@ -24,12 +24,14 @@ class Captain::ConversationInsight < ApplicationRecord
|
|||||||
|
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
belongs_to :captain_unit, class_name: 'Captain::Unit', optional: true
|
belongs_to :captain_unit, class_name: 'Captain::Unit', optional: true
|
||||||
|
belongs_to :inbox, optional: true
|
||||||
|
|
||||||
validates :period_start, :period_end, :status, presence: true
|
validates :period_start, :period_end, :status, presence: true
|
||||||
validates :status, inclusion: { in: STATUSES }
|
validates :status, inclusion: { in: STATUSES }
|
||||||
|
|
||||||
scope :done, -> { where(status: 'done') }
|
scope :done, -> { where(status: 'done') }
|
||||||
scope :for_unit, ->(unit_id) { where(captain_unit_id: unit_id) }
|
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) }
|
scope :for_period, ->(start_date, end_date) { where(period_start: start_date, period_end: end_date) }
|
||||||
|
|
||||||
def mark_processing!
|
def mark_processing!
|
||||||
|
|||||||
@ -3,10 +3,11 @@ class Captain::Llm::ConversationInsightService < Llm::BaseAiService
|
|||||||
|
|
||||||
MAX_CHARS_PER_CHUNK = 40_000
|
MAX_CHARS_PER_CHUNK = 40_000
|
||||||
|
|
||||||
def initialize(account:, unit:, conversations:)
|
def initialize(account:, conversations:, unit: nil, inbox: nil)
|
||||||
super()
|
super()
|
||||||
@account = account
|
@account = account
|
||||||
@unit = unit
|
@unit = unit
|
||||||
|
@inbox = inbox
|
||||||
@conversations = conversations
|
@conversations = conversations
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -23,7 +24,7 @@ class Captain::Llm::ConversationInsightService < Llm::BaseAiService
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
attr_reader :account, :unit, :conversations
|
attr_reader :account, :unit, :inbox, :conversations
|
||||||
|
|
||||||
def build_chunks
|
def build_chunks
|
||||||
texts = conversations.map(&:to_llm_text).reject(&:blank?)
|
texts = conversations.map(&:to_llm_text).reject(&:blank?)
|
||||||
@ -62,8 +63,9 @@ class Captain::Llm::ConversationInsightService < Llm::BaseAiService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def system_prompt
|
def system_prompt
|
||||||
|
entity_name = inbox&.name || unit&.name || 'Geral'
|
||||||
Captain::Llm::SystemPromptsService.conversation_insights_analyzer(
|
Captain::Llm::SystemPromptsService.conversation_insights_analyzer(
|
||||||
unit.name,
|
entity_name,
|
||||||
account.locale_english_name
|
account.locale_english_name
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|||||||
@ -18,17 +18,19 @@ class Captain::Tools::SendSuiteImagesTool < Captain::Tools::BaseTool
|
|||||||
properties: {
|
properties: {
|
||||||
suite_category: {
|
suite_category: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'Opcional. Categoria/tipo da suíte (ex: Hidromassagem, ALEXA, STILO, VL, PrimeAL). ' \
|
description: 'Categoria/tipo da suíte (ex: Hidromassagem, ALEXA, STILO). ' \
|
||||||
'Use quando o cliente mencionar o TIPO/NOME da suíte, não o número.'
|
'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: {
|
suite_number: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'Opcional. Número específico da suíte (ex: 101, 102, 103, 109, 202). ' \
|
description: 'Número específico da suíte (ex: 101, 202, 109). ' \
|
||||||
'Use APENAS quando o cliente mencionar um NÚMERO específico como "suíte 101" ou "quarto 202".'
|
'Use quando o cliente mencionar um NÚMERO como "suíte 101". ' \
|
||||||
|
'Quando fornecido, IGNORA suite_category. Não combine com suite_category.'
|
||||||
},
|
},
|
||||||
limit: {
|
limit: {
|
||||||
type: 'integer',
|
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: {
|
inbox_id: {
|
||||||
type: 'integer',
|
type: 'integer',
|
||||||
@ -150,28 +152,46 @@ class Captain::Tools::SendSuiteImagesTool < Captain::Tools::BaseTool
|
|||||||
hash.key?('conversation')
|
hash.key?('conversation')
|
||||||
end
|
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)
|
def find_items(actual_params)
|
||||||
scope = Captain::GalleryItem
|
suite_number = normalize_text_search(actual_params[:suite_number])
|
||||||
|
category = normalize_text_search(actual_params[:suite_category])
|
||||||
|
|
||||||
|
base_scope = Captain::GalleryItem
|
||||||
.active
|
.active
|
||||||
.where(account_id: @conversation.account_id)
|
.where(account_id: @conversation.account_id)
|
||||||
.includes(image_attachment: :blob)
|
.includes(image_attachment: :blob)
|
||||||
.ordered
|
.ordered
|
||||||
|
|
||||||
unit_id = actual_params[:captain_unit_id].presence
|
if suite_number.present?
|
||||||
scope = scope.where(captain_unit_id: unit_id) if unit_id.present?
|
# Prioridade: número da suíte (match exato normalizado)
|
||||||
|
filters = base_scope.where('LOWER(suite_number) = ?', suite_number)
|
||||||
category = normalize_filter(actual_params[:suite_category])
|
elsif category.present?
|
||||||
suite_number = normalize_filter(actual_params[:suite_number])
|
# Categoria: fuzzy case-insensitive, ignora acentos via REPLACE
|
||||||
|
filters = base_scope.where(
|
||||||
scope = scope.where('LOWER(suite_category) = ?', category.downcase) if category.present?
|
'LOWER(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(suite_category, ' \
|
||||||
scope = scope.where('LOWER(suite_number) = ?', suite_number.downcase) if suite_number.present?
|
"'ã','a'),'â','a'),'á','a'),'à','a'),'é','e'),'ê','e')) " \
|
||||||
|
'= LOWER(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(?, ' \
|
||||||
inbox_scope = scope.where(scope: 'inbox', inbox_id: resolve_target_inbox_id(actual_params))
|
"'ã','a'),'â','a'),'á','a'),'à','a'),'é','e'),'ê','e'))",
|
||||||
return inbox_scope if inbox_scope.exists?
|
category
|
||||||
|
)
|
||||||
scope.where(scope: 'global')
|
else
|
||||||
|
return Captain::GalleryItem.none
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# 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?
|
||||||
|
|
||||||
|
# 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)
|
def find_selected_items(actual_params)
|
||||||
items = find_items(actual_params)
|
items = find_items(actual_params)
|
||||||
return items if items.blank?
|
return items if items.blank?
|
||||||
@ -202,6 +222,12 @@ class Captain::Tools::SendSuiteImagesTool < Captain::Tools::BaseTool
|
|||||||
value.to_s.strip.presence
|
value.to_s.strip.presence
|
||||||
end
|
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)
|
def resolve_target_inbox_id(actual_params)
|
||||||
requested_inbox_id = actual_params[:inbox_id].presence
|
requested_inbox_id = actual_params[:inbox_id].presence
|
||||||
return @conversation.inbox_id if requested_inbox_id.blank?
|
return @conversation.inbox_id if requested_inbox_id.blank?
|
||||||
@ -213,20 +239,23 @@ class Captain::Tools::SendSuiteImagesTool < Captain::Tools::BaseTool
|
|||||||
category = normalize_filter(actual_params[:suite_category])
|
category = normalize_filter(actual_params[:suite_category])
|
||||||
suite_number = normalize_filter(actual_params[:suite_number])
|
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?
|
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
|
else
|
||||||
''
|
''
|
||||||
end
|
end
|
||||||
|
|
||||||
detail = []
|
searched_for = if suite_number.present?
|
||||||
detail << "categoria #{category}" if category.present?
|
"suíte #{suite_number}"
|
||||||
detail << "suíte #{suite_number}" if suite_number.present?
|
elsif category.present?
|
||||||
detail_text = detail.present? ? " para #{detail.join(' e ')}" : ''
|
"categoria #{category}"
|
||||||
|
else
|
||||||
|
'as fotos solicitadas'
|
||||||
|
end
|
||||||
|
|
||||||
success_response(
|
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
|
end
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user