From 87bff8126ce606e61a558dac6bf72607944b7c98 Mon Sep 17 00:00:00 2001 From: Rodrigo Borba Date: Fri, 27 Feb 2026 07:05:58 -0300 Subject: [PATCH] feat(captain): add AI reports page with insights generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../captain/reports/insights_controller.rb | 47 ++- .../dashboard/api/captain/reports.js | 26 ++ .../components-next/sidebar/Sidebar.vue | 6 + .../dashboard/i18n/locale/en/settings.json | 38 +++ .../dashboard/i18n/locale/pt_BR/settings.json | 41 +++ .../settings/captain/captain.routes.js | 9 + .../settings/captain/reports/Index.vue | 299 ++++++++++++++++++ .../dashboard/store/modules/captainReports.js | 64 ++-- ...box_id_to_captain_conversation_insights.rb | 26 ++ .../captain/reports/generate_insights_job.rb | 26 +- .../models/captain/conversation_insight.rb | 2 + .../llm/conversation_insight_service.rb | 8 +- .../captain/tools/send_suite_images_tool.rb | 85 +++-- 13 files changed, 577 insertions(+), 100 deletions(-) create mode 100644 app/javascript/dashboard/api/captain/reports.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/captain/reports/Index.vue create mode 100644 db/migrate/20260227030000_add_inbox_id_to_captain_conversation_insights.rb diff --git a/app/controllers/api/v1/accounts/captain/reports/insights_controller.rb b/app/controllers/api/v1/accounts/captain/reports/insights_controller.rb index 6dc0783a3..2eb0f6746 100644 --- a/app/controllers/api/v1/accounts/captain/reports/insights_controller.rb +++ b/app/controllers/api/v1/accounts/captain/reports/insights_controller.rb @@ -1,13 +1,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, diff --git a/app/javascript/dashboard/api/captain/reports.js b/app/javascript/dashboard/api/captain/reports.js new file mode 100644 index 000000000..b1866f04c --- /dev/null +++ b/app/javascript/dashboard/api/captain/reports.js @@ -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(); diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index 6822182c6..cfc393b38 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -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'), + }, ], }, { diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 8d417d797..e299342ba 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -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." + } } } diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/settings.json b/app/javascript/dashboard/i18n/locale/pt_BR/settings.json index 5b8d76209..e50562fd7 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/settings.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/settings.json @@ -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." + } } } diff --git a/app/javascript/dashboard/routes/dashboard/settings/captain/captain.routes.js b/app/javascript/dashboard/routes/dashboard/settings/captain/captain.routes.js index dd169f5a6..c99b08b3c 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/captain/captain.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/captain/captain.routes.js @@ -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'], + }, + }, ], }, ], diff --git a/app/javascript/dashboard/routes/dashboard/settings/captain/reports/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/captain/reports/Index.vue new file mode 100644 index 000000000..29428220a --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/captain/reports/Index.vue @@ -0,0 +1,299 @@ + + + diff --git a/app/javascript/dashboard/store/modules/captainReports.js b/app/javascript/dashboard/store/modules/captainReports.js index e82dbce81..338c85b2b 100644 --- a/app/javascript/dashboard/store/modules/captainReports.js +++ b/app/javascript/dashboard/store/modules/captainReports.js @@ -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, diff --git a/db/migrate/20260227030000_add_inbox_id_to_captain_conversation_insights.rb b/db/migrate/20260227030000_add_inbox_id_to_captain_conversation_insights.rb new file mode 100644 index 000000000..226043768 --- /dev/null +++ b/db/migrate/20260227030000_add_inbox_id_to_captain_conversation_insights.rb @@ -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 diff --git a/enterprise/app/jobs/captain/reports/generate_insights_job.rb b/enterprise/app/jobs/captain/reports/generate_insights_job.rb index ecb7390b2..f0f3eda2c 100644 --- a/enterprise/app/jobs/captain/reports/generate_insights_job.rb +++ b/enterprise/app/jobs/captain/reports/generate_insights_job.rb @@ -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 diff --git a/enterprise/app/models/captain/conversation_insight.rb b/enterprise/app/models/captain/conversation_insight.rb index 1f34f8f71..6b489a364 100644 --- a/enterprise/app/models/captain/conversation_insight.rb +++ b/enterprise/app/models/captain/conversation_insight.rb @@ -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! diff --git a/enterprise/app/services/captain/llm/conversation_insight_service.rb b/enterprise/app/services/captain/llm/conversation_insight_service.rb index baede0360..c6ad64a5b 100644 --- a/enterprise/app/services/captain/llm/conversation_insight_service.rb +++ b/enterprise/app/services/captain/llm/conversation_insight_service.rb @@ -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 diff --git a/enterprise/app/services/captain/tools/send_suite_images_tool.rb b/enterprise/app/services/captain/tools/send_suite_images_tool.rb index 6083c1df1..6bf282481 100644 --- a/enterprise/app/services/captain/tools/send_suite_images_tool.rb +++ b/enterprise/app/services/captain/tools/send_suite_images_tool.rb @@ -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