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:
parent
c022f4ce5d
commit
972fc5c67b
@ -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
|
||||
@ -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
|
||||
@ -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,
|
||||
});
|
||||
|
||||
111
app/javascript/dashboard/store/modules/captainReports.js
Normal file
111
app/javascript/dashboard/store/modules/captainReports.js
Normal 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,
|
||||
};
|
||||
@ -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',
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
18
db/migrate/20260226230001_create_captain_report_snapshots.rb
Normal file
18
db/migrate/20260226230001_create_captain_report_snapshots.rb
Normal 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
|
||||
49
db/schema.rb
49
db/schema.rb
@ -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"
|
||||
|
||||
91
enterprise/app/jobs/captain/reports/daily_snapshot_job.rb
Normal file
91
enterprise/app/jobs/captain/reports/daily_snapshot_job.rb
Normal 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
|
||||
60
enterprise/app/jobs/captain/reports/generate_insights_job.rb
Normal file
60
enterprise/app/jobs/captain/reports/generate_insights_job.rb
Normal 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
|
||||
24
enterprise/app/jobs/captain/reports/weekly_insights_job.rb
Normal file
24
enterprise/app/jobs/captain/reports/weekly_insights_job.rb
Normal 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
|
||||
63
enterprise/app/models/captain/conversation_insight.rb
Normal file
63
enterprise/app/models/captain/conversation_insight.rb
Normal 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
|
||||
23
enterprise/app/models/captain/report_snapshot.rb
Normal file
23
enterprise/app/models/captain/report_snapshot.rb
Normal 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
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user