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
|
||||
# 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,
|
||||
|
||||
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'],
|
||||
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",
|
||||
"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_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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@ -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 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,
|
||||
|
||||
@ -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
|
||||
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
|
||||
|
||||
@ -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!
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user