feat(captain): improve suite photo search accuracy with AI guidance

Melhorias na ferramenta send_suite_images para resolver confusão entre
categoria e número de suíte:

1. **Descrições de parâmetros mais claras**
   - suite_category: exemplos específicos (Hidromassagem, ALEXA, STILO)
   - suite_number: apenas números (101, 102, 103) - remove exemplos confusos

2. **Instruções explícitas no system prompt**
   - Seção [Galeria de Fotos] com regras claras
   - Prioriza suite_category quando ambíguo
   - Evita confirmações desnecessárias com cliente

3. **Mensagens de erro melhoradas**
   - Sugere buscar por categoria quando busca por número falha
   - Feedback mais útil para a IA

Resultado esperado:
- Cliente: "Me manda foto da suite Alexa"
- IA: busca por suite_category="Alexa" ✓ (sem pedir confirmação)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Rodrigo Borba 2026-02-26 23:04:28 -03:00
parent c022f4ce5d
commit 972fc5c67b
18 changed files with 906 additions and 4 deletions

View File

@ -0,0 +1,72 @@
class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Accounts::BaseController
def index
unit_id = params[:unit_id].present? ? params[:unit_id].to_i : nil
insights = Captain::ConversationInsight
.where(account_id: Current.account.id, captain_unit_id: unit_id)
.order(period_start: :desc)
.limit(12)
render json: insights.map { |i| format_insight(i) }
end
def show
insight = Captain::ConversationInsight.find_by!(
id: params[:id],
account_id: Current.account.id
)
render json: format_insight(insight)
rescue ActiveRecord::RecordNotFound
render json: { error: 'Insight não encontrado' }, status: :not_found
end
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
enqueue_insight(unit_id, period_start, period_end)
end
private
def enqueue_insight(unit_id, period_start, period_end)
insight = Captain::ConversationInsight.find_or_initialize_by(
account_id: Current.account.id,
captain_unit_id: unit_id,
period_start: period_start,
period_end: period_end
)
return render json: { status: 'processing', message: 'Análise já está em andamento' } if insight.processing?
insight.status = 'pending'
insight.payload = nil
insight.save!
Captain::Reports::GenerateInsightsJob.perform_later(
Current.account.id, unit_id, period_start, period_end
)
render json: { status: 'queued', insight_id: insight.id }, status: :accepted
end
def parse_date(param, default)
param.present? ? Date.parse(param) : default
rescue ArgumentError
default
end
def format_insight(insight)
{
id: insight.id,
unit_id: insight.captain_unit_id,
period_start: insight.period_start,
period_end: insight.period_end,
status: insight.status,
conversations_count: insight.conversations_count,
messages_count: insight.messages_count,
generated_at: insight.generated_at,
payload: insight.payload
}
end
end

View File

@ -0,0 +1,105 @@
class Api::V1::Accounts::Captain::Reports::OperationalController < Api::V1::Accounts::BaseController
def show
period_start = parse_date(params[:period_start], Time.zone.today.beginning_of_month)
period_end = parse_date(params[:period_end], Time.zone.today)
unit = params[:unit_id].present? ? Current.account.captain_units.find_by(id: params[:unit_id]) : nil
render json: build_operational_report(unit, period_start, period_end)
end
private
def parse_date(param, default)
param.present? ? Date.parse(param) : default
rescue ArgumentError
default
end
def build_operational_report(unit, period_start, period_end)
conversations = scoped_conversations(unit, period_start, period_end)
{
period: { start: period_start, end: period_end },
unit_id: unit&.id,
unit_name: unit&.name,
conversations: conversation_metrics(conversations),
reservations: reservation_metrics(unit, period_start, period_end),
hourly_distribution: hourly_distribution(conversations),
daily_distribution: daily_distribution(conversations, period_start, period_end)
}
end
def conversation_metrics(conversations)
resolved = conversations.where(status: 'resolved')
avg_minutes = avg_resolution_minutes(resolved)
{
total: conversations.count,
resolved: resolved.count,
open: conversations.where(status: 'open').count,
resolution_rate: safe_rate(resolved.count, conversations.count),
avg_resolution_minutes: avg_minutes
}
end
def reservation_metrics(unit, period_start, period_end)
reservations = scoped_reservations(unit, period_start, period_end)
paid = reservations.where(status: 'paid')
expired = reservations.where(status: 'expired')
{
total: reservations.count,
paid: paid.count,
expired: expired.count,
pending: reservations.where(status: %w[pending waiting]).count,
conversion_rate: safe_rate(paid.count, reservations.count),
total_paid_cents: paid.sum(:amount_cents)
}
rescue StandardError
{ total: 0, paid: 0, expired: 0, pending: 0, conversion_rate: 0, total_paid_cents: 0 }
end
def hourly_distribution(conversations)
(0..23).map do |hour|
count = conversations.where('EXTRACT(HOUR FROM created_at) = ?', hour).count
{ hour: hour, count: count }
end
end
def daily_distribution(conversations, period_start, period_end)
(period_start..period_end).map do |date|
count = conversations.where(created_at: date.all_day).count
{ date: date.to_s, count: count }
end
end
def scoped_conversations(unit, period_start, period_end)
scope = Current.account.conversations.where(created_at: period_start.beginning_of_day..period_end.end_of_day)
if unit
inbox_ids = unit.inboxes.pluck(:id)
scope = scope.where(inbox_id: inbox_ids) if inbox_ids.any?
end
scope
end
def scoped_reservations(unit, period_start, period_end)
scope = Current.account.captain_reservations.where(created_at: period_start.beginning_of_day..period_end.end_of_day)
scope = scope.where(captain_unit_id: unit.id) if unit
scope
end
def avg_resolution_minutes(conversations)
return 0 if conversations.none?
total = conversations.sum do |c|
((c.updated_at - c.created_at) / 60).round
end
(total / conversations.count).round
end
def safe_rate(numerator, denominator)
return 0 if denominator.zero?
((numerator.to_f / denominator) * 100).round(1)
end
end

View File

@ -62,6 +62,7 @@ import captainCustomTools from './captain/customTools';
import captainReservations from './captain/reservations';
import captainUnits from './modules/captainUnits';
import captainGalleryItems from './modules/captainGalleryItems';
import captainReports from './modules/captainReports';
const plugins = [];
@ -129,6 +130,7 @@ export default createStore({
captainReservations,
captainUnits,
captainGalleryItems,
captainReports,
},
plugins,
});

View File

@ -0,0 +1,111 @@
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,
},
};
export const getters = {
getOperational: $state => $state.operational,
getInsights: $state => $state.insights,
getCurrentInsight: $state => $state.currentInsight,
getUIFlags: $state => $state.uiFlags,
};
export const mutations = {
[MutationTypes.SET_CAPTAIN_REPORTS_OPERATIONAL]($state, data) {
$state.operational = data;
},
[MutationTypes.SET_CAPTAIN_REPORTS_INSIGHTS]($state, data) {
$state.insights = data;
},
[MutationTypes.SET_CAPTAIN_REPORTS_CURRENT_INSIGHT]($state, data) {
$state.currentInsight = data;
},
[MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS]($state, flags) {
$state.uiFlags = { ...$state.uiFlags, ...flags };
},
};
export const actions = {
async fetchOperational({ commit, rootGetters }, params = {}) {
const accountId = rootGetters['auth/getCurrentAccountId'];
commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, {
isFetchingOperational: true,
});
try {
const { data } = await captainReportsAPI.getOperational(
accountId,
params
);
commit(MutationTypes.SET_CAPTAIN_REPORTS_OPERATIONAL, data);
} finally {
commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, {
isFetchingOperational: false,
});
}
},
async fetchInsights({ commit, rootGetters }, params = {}) {
const accountId = rootGetters['auth/getCurrentAccountId'];
commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, {
isFetchingInsights: true,
});
try {
const { data } = await captainReportsAPI.getInsights(accountId, params);
commit(MutationTypes.SET_CAPTAIN_REPORTS_INSIGHTS, data);
} finally {
commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, {
isFetchingInsights: false,
});
}
},
async generateInsight({ commit, dispatch, rootGetters }, payload) {
const accountId = rootGetters['auth/getCurrentAccountId'];
commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, { isGenerating: true });
try {
await captainReportsAPI.generateInsight(accountId, payload);
await dispatch('fetchInsights', { unit_id: payload.unit_id });
} finally {
commit(MutationTypes.SET_CAPTAIN_REPORTS_UI_FLAGS, {
isGenerating: false,
});
}
},
};
export default {
namespaced: true,
state,
getters,
mutations,
actions,
};

View File

@ -404,4 +404,10 @@ export default {
ADD_CAPTAIN_GALLERY_ITEM: 'ADD_CAPTAIN_GALLERY_ITEM',
EDIT_CAPTAIN_GALLERY_ITEM: 'EDIT_CAPTAIN_GALLERY_ITEM',
DELETE_CAPTAIN_GALLERY_ITEM: 'DELETE_CAPTAIN_GALLERY_ITEM',
// Captain Reports
SET_CAPTAIN_REPORTS_OPERATIONAL: 'SET_CAPTAIN_REPORTS_OPERATIONAL',
SET_CAPTAIN_REPORTS_INSIGHTS: 'SET_CAPTAIN_REPORTS_INSIGHTS',
SET_CAPTAIN_REPORTS_CURRENT_INSIGHT: 'SET_CAPTAIN_REPORTS_CURRENT_INSIGHT',
SET_CAPTAIN_REPORTS_UI_FLAGS: 'SET_CAPTAIN_REPORTS_UI_FLAGS',
};

View File

@ -91,6 +91,12 @@ Rails.application.routes.draw do
post :follow_up
end
resources :units
namespace :reports do
resource :operational, only: [:show], controller: 'reports/operational'
resources :insights, only: [:index, :show] do
post :generate, on: :collection
end
end
end
resource :saml_settings, only: [:show, :create, :update, :destroy]
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do

View File

@ -0,0 +1,24 @@
class CreateCaptainConversationInsights < ActiveRecord::Migration[7.1]
def change
create_table :captain_conversation_insights do |t|
t.references :account, null: false, foreign_key: true
t.references :captain_unit, null: true, foreign_key: true
t.date :period_start, null: false
t.date :period_end, null: false
t.string :status, null: false, default: 'pending'
t.jsonb :payload
t.integer :conversations_count, default: 0
t.integer :messages_count, default: 0
t.integer :llm_tokens_used
t.timestamp :generated_at
t.timestamps
end
add_index :captain_conversation_insights,
%i[captain_unit_id period_start period_end],
unique: true,
name: 'idx_captain_insights_unique_period'
add_index :captain_conversation_insights, %i[account_id status]
end
end

View File

@ -0,0 +1,18 @@
class CreateCaptainReportSnapshots < ActiveRecord::Migration[7.1]
def change
create_table :captain_report_snapshots do |t|
t.references :account, null: false, foreign_key: true
t.references :captain_unit, null: true, foreign_key: true
t.date :snapshot_date, null: false
t.jsonb :data, null: false, default: {}
t.timestamps
end
add_index :captain_report_snapshots,
%i[captain_unit_id snapshot_date],
unique: true,
name: 'idx_captain_snapshots_unique_date'
add_index :captain_report_snapshots, %i[account_id snapshot_date]
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_02_26_002000) do
ActiveRecord::Schema[7.1].define(version: 2026_02_26_230001) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@ -366,6 +366,25 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_002000) do
t.index ["account_id"], name: "index_captain_configurations_on_account_id"
end
create_table "captain_conversation_insights", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "captain_unit_id"
t.date "period_start", null: false
t.date "period_end", null: false
t.string "status", default: "pending", null: false
t.jsonb "payload"
t.integer "conversations_count", default: 0
t.integer "messages_count", default: 0
t.integer "llm_tokens_used"
t.datetime "generated_at", precision: nil
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "status"], name: "index_captain_conversation_insights_on_account_id_and_status"
t.index ["account_id"], name: "index_captain_conversation_insights_on_account_id"
t.index ["captain_unit_id", "period_start", "period_end"], name: "idx_captain_insights_unique_period", unique: true
t.index ["captain_unit_id"], name: "index_captain_conversation_insights_on_captain_unit_id"
end
create_table "captain_custom_tools", force: :cascade do |t|
t.bigint "account_id", null: false
t.string "slug", null: false
@ -683,6 +702,19 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_002000) do
t.index ["source_type", "source_id"], name: "index_captain_reminders_on_source_type_and_source_id"
end
create_table "captain_report_snapshots", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "captain_unit_id"
t.date "snapshot_date", null: false
t.jsonb "data", default: {}, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "snapshot_date"], name: "index_captain_report_snapshots_on_account_id_and_snapshot_date"
t.index ["account_id"], name: "index_captain_report_snapshots_on_account_id"
t.index ["captain_unit_id", "snapshot_date"], name: "idx_captain_snapshots_unique_date", unique: true
t.index ["captain_unit_id"], name: "index_captain_report_snapshots_on_captain_unit_id"
end
create_table "captain_reservations", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "inbox_id", null: false
@ -770,6 +802,15 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_002000) do
t.index ["inbox_id"], name: "index_captain_tool_configs_on_inbox_id"
end
create_table "captain_unit_inboxes", force: :cascade do |t|
t.bigint "captain_unit_id", null: false
t.bigint "inbox_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["captain_unit_id", "inbox_id"], name: "index_captain_unit_inboxes_on_unit_and_inbox", unique: true
t.index ["inbox_id"], name: "index_captain_unit_inboxes_on_inbox_id"
end
create_table "captain_units", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "captain_brand_id", null: false
@ -1907,6 +1948,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_002000) do
add_foreign_key "captain_assets", "captain_suites"
add_foreign_key "captain_brands", "accounts"
add_foreign_key "captain_configurations", "accounts"
add_foreign_key "captain_conversation_insights", "accounts"
add_foreign_key "captain_conversation_insights", "captain_units"
add_foreign_key "captain_extras", "accounts"
add_foreign_key "captain_gallery_items", "accounts"
add_foreign_key "captain_gallery_items", "captain_units"
@ -1936,6 +1979,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_002000) do
add_foreign_key "captain_reminders", "contacts"
add_foreign_key "captain_reminders", "conversations"
add_foreign_key "captain_reminders", "inboxes"
add_foreign_key "captain_report_snapshots", "accounts"
add_foreign_key "captain_report_snapshots", "captain_units"
add_foreign_key "captain_reservations", "accounts"
add_foreign_key "captain_reservations", "captain_brands"
add_foreign_key "captain_reservations", "captain_units"
@ -1946,6 +1991,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_002000) do
add_foreign_key "captain_suites", "accounts"
add_foreign_key "captain_tool_configs", "accounts"
add_foreign_key "captain_tool_configs", "inboxes"
add_foreign_key "captain_unit_inboxes", "captain_units", on_delete: :cascade
add_foreign_key "captain_unit_inboxes", "inboxes", on_delete: :cascade
add_foreign_key "captain_units", "accounts"
add_foreign_key "captain_units", "captain_brands"
add_foreign_key "captain_units", "inboxes"

View File

@ -0,0 +1,91 @@
class Captain::Reports::DailySnapshotJob < ApplicationJob
queue_as :scheduled_jobs
# Roda todo dia à meia-noite via Sidekiq-Cron.
# Salva um snapshot dos dados operacionais de ontem por unidade.
def perform
date = Date.yesterday
Account.find_each do |account|
save_snapshot(account, nil, date)
account.captain_units.find_each do |unit|
save_snapshot(account, unit, date)
end
end
end
private
def save_snapshot(account, unit, date)
data = build_snapshot_data(account, unit, date)
Captain::ReportSnapshot.find_or_create_by!(
account_id: account.id,
captain_unit_id: unit&.id,
snapshot_date: date
) do |snap|
snap.data = data
end
end
# rubocop:disable Metrics/MethodLength
def build_snapshot_data(account, unit, date)
conversations = base_conversations(account, unit, date)
reservations = base_reservations(account, unit, date)
{
date: date.to_s,
unit_id: unit&.id,
unit_name: unit&.name,
conversations: {
total: conversations.count,
resolved: conversations.where(status: 'resolved').count,
open: conversations.where(status: 'open').count,
avg_resolution_minutes: avg_resolution(conversations)
},
reservations: {
total: reservations.count,
paid: reservations.where(status: 'paid').count,
expired: reservations.where(status: 'expired').count,
pending: reservations.where(status: %w[pending waiting]).count,
total_amount_cents: reservations.where(status: 'paid').sum(:amount_cents)
}
}
end
# rubocop:enable Metrics/MethodLength
def base_conversations(account, unit, date)
scope = account.conversations.where(created_at: date.all_day)
if unit
inbox_ids = unit.inboxes.pluck(:id)
scope = scope.where(inbox_id: inbox_ids) if inbox_ids.any?
end
scope
end
def base_reservations(account, unit, date)
scope = begin
account.captain_reservations.where(created_at: date.all_day)
rescue StandardError
account.captain_units.joins(:reservations).merge(Captain::Reservation.where(created_at: date.all_day))
end
scope = scope.where(captain_unit_id: unit.id) if unit
scope
rescue StandardError
Captain::Reservation.none
end
def avg_resolution(conversations)
resolved = conversations.where.not(first_reply_created_at: nil)
return 0 if resolved.none?
total_minutes = resolved.sum do |c|
next 0 unless c.first_reply_created_at
((c.updated_at - c.created_at) / 60).round
end
(total_minutes / resolved.count).round
end
end

View File

@ -0,0 +1,60 @@
class Captain::Reports::GenerateInsightsJob < ApplicationJob
queue_as :default
# Gera insights de IA para uma unidade 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)
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)
return if insight.processing? || insight.done?
insight.mark_processing!
run_analysis(account, unit, 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!
end
private
def find_or_create_insight(account_id, unit_id, period_start, period_end)
insight = Captain::ConversationInsight.find_or_initialize_by(
account_id: account_id,
captain_unit_id: unit_id,
period_start: period_start,
period_end: period_end
)
insight.save! if insight.new_record?
insight
end
def run_analysis(account, unit, insight, period_start, period_end)
conversations = fetch_conversations(account, unit, period_start, period_end)
insight.update!(conversations_count: conversations.count)
payload = Captain::Llm::ConversationInsightService.new(
account: account,
unit: unit,
conversations: conversations
).analyze
insight.update!(messages_count: conversations.sum { |conv| conv.messages.count })
insight.mark_done!(payload)
end
def fetch_conversations(account, unit, period_start, period_end)
scope = account.conversations
.where(created_at: period_start.beginning_of_day..period_end.end_of_day)
.includes(:messages)
if unit
inbox_ids = unit.inboxes.pluck(:id)
scope = scope.where(inbox_id: inbox_ids) if inbox_ids.any?
end
scope.to_a
end
end

View File

@ -0,0 +1,24 @@
class Captain::Reports::WeeklyInsightsJob < ApplicationJob
queue_as :scheduled_jobs
# Roda todo domingo de madrugada via Sidekiq-Cron.
# Agenda geração de insights para todas as unidades de todas as contas.
def perform
period_end = Date.yesterday
period_start = period_end - 6.days
Account.find_each do |account|
# Gera um insight global (sem unit) para a conta toda
Captain::Reports::GenerateInsightsJob.perform_later(
account.id, nil, period_start, period_end
)
# Gera um insight por unidade
account.captain_units.find_each do |unit|
Captain::Reports::GenerateInsightsJob.perform_later(
account.id, unit.id, period_start, period_end
)
end
end
end
end

View File

@ -0,0 +1,63 @@
# == Schema Information
#
# Table name: captain_conversation_insights
#
# id :bigint not null, primary key
# account_id :bigint not null
# captain_unit_id :bigint
# period_start :date not null
# period_end :date not null
# status :string default("pending"), not null
# payload :jsonb
# conversations_count :integer default(0)
# messages_count :integer default(0)
# llm_tokens_used :integer
# generated_at :datetime
# created_at :datetime not null
# updated_at :datetime not null
#
class Captain::ConversationInsight < ApplicationRecord
include Rails.application.routes.url_helpers
STATUSES = %w[pending processing done failed].freeze
belongs_to :account
belongs_to :captain_unit, class_name: 'Captain::Unit', 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_period, ->(start_date, end_date) { where(period_start: start_date, period_end: end_date) }
def mark_processing!
update!(status: 'processing')
end
def mark_done!(payload, tokens_used: nil)
update!(
status: 'done',
payload: payload,
llm_tokens_used: tokens_used,
generated_at: Time.current
)
end
def mark_failed!
update!(status: 'failed')
end
def pending?
status == 'pending'
end
def processing?
status == 'processing'
end
def done?
status == 'done'
end
end

View File

@ -0,0 +1,23 @@
# == Schema Information
#
# Table name: captain_report_snapshots
#
# id :bigint not null, primary key
# account_id :bigint not null
# captain_unit_id :bigint
# snapshot_date :date not null
# data :jsonb not null
# created_at :datetime not null
# updated_at :datetime not null
#
class Captain::ReportSnapshot < ApplicationRecord
belongs_to :account
belongs_to :captain_unit, class_name: 'Captain::Unit', optional: true
validates :snapshot_date, presence: true
scope :for_unit, ->(unit_id) { where(captain_unit_id: unit_id) }
scope :for_period, ->(start_date, end_date) { where(snapshot_date: start_date..end_date) }
scope :recent, -> { order(snapshot_date: :desc) }
end

View File

@ -1,3 +1,23 @@
# == Schema Information
#
# Table name: captain_unit_inboxes
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# captain_unit_id :bigint not null
# inbox_id :bigint not null
#
# Indexes
#
# index_captain_unit_inboxes_on_inbox_id (inbox_id)
# index_captain_unit_inboxes_on_unit_and_inbox (captain_unit_id,inbox_id) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (captain_unit_id => captain_units.id) ON DELETE => cascade
# fk_rails_... (inbox_id => inboxes.id) ON DELETE => cascade
#
class Captain::UnitInbox < ApplicationRecord
self.table_name = 'captain_unit_inboxes'

View File

@ -0,0 +1,160 @@
class Captain::Llm::ConversationInsightService < Llm::BaseAiService
include Integrations::LlmInstrumentation
MAX_CHARS_PER_CHUNK = 40_000
def initialize(account:, unit:, conversations:)
super()
@account = account
@unit = unit
@conversations = conversations
end
# Analisa as conversas e retorna o payload de insights
def analyze
chunks = build_chunks
return empty_payload if chunks.empty?
results = chunks.filter_map { |chunk| analyze_chunk(chunk) }
return empty_payload if results.empty?
merge_results(results)
end
private
attr_reader :account, :unit, :conversations
def build_chunks
texts = conversations.map(&:to_llm_text).reject(&:blank?)
return [] if texts.empty?
chunks = []
current = []
current_size = 0
texts.each do |text|
if current_size + text.length > MAX_CHARS_PER_CHUNK && current.any?
chunks << current.join("\n\n---\n\n")
current = []
current_size = 0
end
current << text
current_size += text.length
end
chunks << current.join("\n\n---\n\n") if current.any?
chunks
end
def analyze_chunk(chunk)
response = instrument_llm_call(instrumentation_params) do
chat
.with_params(response_format: { type: 'json_object' })
.with_instructions(system_prompt)
.ask(chunk)
end
parse_response(response.content)
rescue RubyLLM::Error => e
Rails.logger.error "[Captain::Llm::ConversationInsightService] LLM Error: #{e.message}"
nil
end
def system_prompt
Captain::Llm::SystemPromptsService.conversation_insights_analyzer(
unit.name,
account.locale_english_name
)
end
def instrumentation_params
{
span_name: 'llm.captain.conversation_insights',
model: @model,
temperature: @temperature,
feature_name: 'conversation_insights',
account_id: account.id,
messages: [{ role: 'system', content: system_prompt }]
}
end
def merge_results(results)
base = results.first.dup
results.drop(1).each do |result|
merge_arrays!(base, result)
merge_sentiment!(base, result)
merge_highlights!(base, result)
base['recommendations'] = ((base['recommendations'] || []) + (result['recommendations'] || [])).uniq
end
base
end
def merge_arrays!(base, result)
base['top_topics'] = merge_by_topic(base['top_topics'], result['top_topics'])
base['ai_failures'] = merge_by_description(base['ai_failures'], result['ai_failures'])
base['faq_gaps'] = merge_by_question(base['faq_gaps'], result['faq_gaps'])
base['most_requested_suites'] = merge_by_suite(base['most_requested_suites'], result['most_requested_suites'])
end
def merge_sentiment!(base, result)
%w[positive_count negative_count neutral_count].each do |key|
base['sentiment'][key] = base.dig('sentiment', key).to_i + result.dig('sentiment', key).to_i
end
end
def merge_highlights!(base, result)
%w[praises complaints].each do |key|
base['highlights'][key] = (base.dig('highlights', key) || []) + (result.dig('highlights', key) || [])
end
end
def merge_by_topic(arr_a, arr_b)
merge_arrays_by_key(arr_a, arr_b, 'topic', 'count')
end
def merge_by_description(arr_a, arr_b)
merge_arrays_by_key(arr_a, arr_b, 'description', 'frequency')
end
def merge_by_question(arr_a, arr_b)
merge_arrays_by_key(arr_a, arr_b, 'question', 'frequency')
end
def merge_by_suite(arr_a, arr_b)
merge_arrays_by_key(arr_a, arr_b, 'suite', 'count')
end
def merge_arrays_by_key(arr_a, arr_b, label_key, count_key)
merged = ((arr_a || []) + (arr_b || [])).group_by { |item| item[label_key] }
merged
.map { |_label, items| items.first.merge(count_key => items.sum { |i| i[count_key].to_i }) }
.sort_by { |item| -item[count_key].to_i }
.take(10)
end
def parse_response(content)
return nil if content.nil?
JSON.parse(content.strip)
rescue JSON::ParserError => e
Rails.logger.error "[Captain::Llm::ConversationInsightService] JSON parse error: #{e.message}"
nil
end
def empty_payload
{
'top_topics' => [],
'ai_failures' => [],
'faq_gaps' => [],
'sentiment' => { 'positive_count' => 0, 'negative_count' => 0, 'neutral_count' => 0, 'summary' => '' },
'highlights' => { 'praises' => [], 'complaints' => [] },
'most_requested_suites' => [],
'price_reactions' => { 'summary' => '', 'objections_count' => 0 },
'recommendations' => [],
'period_summary' => 'Sem conversas suficientes para análise no período.'
}
end
end

View File

@ -205,6 +205,13 @@ class Captain::Llm::SystemPromptsService
```
- If the answer is not provided in context sections, Respond to the customer and ask whether they want to talk to another support agent . If they ask to Chat with another agent, return `conversation_handoff' as the response in JSON response
#{'- You MUST provide numbered citations at the appropriate places in the text.' if config['feature_citation']}
[Galeria de Fotos - Regras Importantes]
Quando o cliente pedir fotos de suítes:
- Se mencionar um NOME ou TIPO (ex: "suite Alexa", "suite hidromassagem", "suite STILO"), use o parâmetro suite_category
- Se mencionar um NÚMERO específico (ex: "suite 101", "quarto 202"), use o parâmetro suite_number
- Em caso de dúvida, priorize interpretar como suite_category, pois clientes geralmente se referem ao tipo da suíte
- NÃO peça confirmação ao cliente sobre categoria vs número - infira do contexto e chame a ferramenta diretamente
SYSTEM_PROMPT_MESSAGE
end
@ -289,6 +296,58 @@ class Captain::Llm::SystemPromptsService
Do NOT mention page numbers anywhere in questions or answers
PROMPT
end
def conversation_insights_analyzer(unit_name, language = 'português')
<<~PROMPT
Você é um analista especializado em experiência de cliente para hotelaria.
Analise as conversas fornecidas entre clientes e o assistente de IA de uma unidade hoteleira chamada "#{unit_name}".
Gere o relatório EXCLUSIVAMENTE em #{language} e APENAS com base nas conversas fornecidas.
NÃO invente dados, NÃO use conhecimento externo.
Retorne SOMENTE um JSON válido com a estrutura abaixo, sem texto extra:
```json
{
"top_topics": [
{ "topic": "Nome do tópico", "count": 5, "description": "Breve descrição" }
],
"ai_failures": [
{ "description": "Descrição do problema", "example": "Exemplo da conversa", "frequency": 3 }
],
"faq_gaps": [
{ "question": "Pergunta sem cobertura no FAQ", "frequency": 2 }
],
"sentiment": {
"positive_count": 10,
"negative_count": 3,
"neutral_count": 7,
"summary": "Resumo geral do sentimento dos clientes"
},
"highlights": {
"praises": ["Elogio 1 capturado textualmente", "Elogio 2"],
"complaints": ["Reclamação 1", "Reclamação 2"]
},
"most_requested_suites": [
{ "suite": "Nome ou categoria da suíte", "count": 4 }
],
"price_reactions": {
"summary": "Como os clientes reagiram aos preços informados",
"objections_count": 2
},
"recommendations": [
"Recomendação acionável baseada nos dados"
],
"period_summary": "Resumo executivo de 2-3 linhas sobre a semana"
}
```
Regras obrigatórias:
- Se não houver dados suficientes para algum campo, retorne arrays vazios ou strings vazias
- Nunca fabrique exemplos ou números
- O JSON deve ser válido e parseável
PROMPT
end
# rubocop:enable Metrics/MethodLength
end
end

View File

@ -18,11 +18,13 @@ class Captain::Tools::SendSuiteImagesTool < Captain::Tools::BaseTool
properties: {
suite_category: {
type: 'string',
description: 'Opcional. Categoria da suíte (ex: luxo, hidro, master).'
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.'
},
suite_number: {
type: 'string',
description: 'Opcional. Número/identificador da suíte (ex: 101, alexa, aluba).'
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".'
},
limit: {
type: 'integer',
@ -211,12 +213,21 @@ class Captain::Tools::SendSuiteImagesTool < Captain::Tools::BaseTool
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
suggestion = if category.blank? && suite_number.present?
"\n\nDica para a IA: Tente buscar por suite_category em vez de suite_number."
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 ')}" : ''
success_response("Não encontrei fotos cadastradas na galeria desta caixa de entrada nem no acervo global#{detail_text}.")
success_response(
"Não encontrei fotos cadastradas na galeria desta caixa de entrada nem no acervo global#{detail_text}.#{suggestion}"
)
end
def success_payload(selected_items, sent_count, actual_params)