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 captainReservations from './captain/reservations';
|
||||||
import captainUnits from './modules/captainUnits';
|
import captainUnits from './modules/captainUnits';
|
||||||
import captainGalleryItems from './modules/captainGalleryItems';
|
import captainGalleryItems from './modules/captainGalleryItems';
|
||||||
|
import captainReports from './modules/captainReports';
|
||||||
|
|
||||||
const plugins = [];
|
const plugins = [];
|
||||||
|
|
||||||
@ -129,6 +130,7 @@ export default createStore({
|
|||||||
captainReservations,
|
captainReservations,
|
||||||
captainUnits,
|
captainUnits,
|
||||||
captainGalleryItems,
|
captainGalleryItems,
|
||||||
|
captainReports,
|
||||||
},
|
},
|
||||||
plugins,
|
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',
|
ADD_CAPTAIN_GALLERY_ITEM: 'ADD_CAPTAIN_GALLERY_ITEM',
|
||||||
EDIT_CAPTAIN_GALLERY_ITEM: 'EDIT_CAPTAIN_GALLERY_ITEM',
|
EDIT_CAPTAIN_GALLERY_ITEM: 'EDIT_CAPTAIN_GALLERY_ITEM',
|
||||||
DELETE_CAPTAIN_GALLERY_ITEM: 'DELETE_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
|
post :follow_up
|
||||||
end
|
end
|
||||||
resources :units
|
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
|
end
|
||||||
resource :saml_settings, only: [:show, :create, :update, :destroy]
|
resource :saml_settings, only: [:show, :create, :update, :destroy]
|
||||||
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
|
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.
|
# 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
|
# These extensions should be enabled to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
enable_extension "pg_trgm"
|
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"
|
t.index ["account_id"], name: "index_captain_configurations_on_account_id"
|
||||||
end
|
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|
|
create_table "captain_custom_tools", force: :cascade do |t|
|
||||||
t.bigint "account_id", null: false
|
t.bigint "account_id", null: false
|
||||||
t.string "slug", 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"
|
t.index ["source_type", "source_id"], name: "index_captain_reminders_on_source_type_and_source_id"
|
||||||
end
|
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|
|
create_table "captain_reservations", force: :cascade do |t|
|
||||||
t.bigint "account_id", null: false
|
t.bigint "account_id", null: false
|
||||||
t.bigint "inbox_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"
|
t.index ["inbox_id"], name: "index_captain_tool_configs_on_inbox_id"
|
||||||
end
|
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|
|
create_table "captain_units", force: :cascade do |t|
|
||||||
t.bigint "account_id", null: false
|
t.bigint "account_id", null: false
|
||||||
t.bigint "captain_brand_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_assets", "captain_suites"
|
||||||
add_foreign_key "captain_brands", "accounts"
|
add_foreign_key "captain_brands", "accounts"
|
||||||
add_foreign_key "captain_configurations", "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_extras", "accounts"
|
||||||
add_foreign_key "captain_gallery_items", "accounts"
|
add_foreign_key "captain_gallery_items", "accounts"
|
||||||
add_foreign_key "captain_gallery_items", "captain_units"
|
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", "contacts"
|
||||||
add_foreign_key "captain_reminders", "conversations"
|
add_foreign_key "captain_reminders", "conversations"
|
||||||
add_foreign_key "captain_reminders", "inboxes"
|
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", "accounts"
|
||||||
add_foreign_key "captain_reservations", "captain_brands"
|
add_foreign_key "captain_reservations", "captain_brands"
|
||||||
add_foreign_key "captain_reservations", "captain_units"
|
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_suites", "accounts"
|
||||||
add_foreign_key "captain_tool_configs", "accounts"
|
add_foreign_key "captain_tool_configs", "accounts"
|
||||||
add_foreign_key "captain_tool_configs", "inboxes"
|
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", "accounts"
|
||||||
add_foreign_key "captain_units", "captain_brands"
|
add_foreign_key "captain_units", "captain_brands"
|
||||||
add_foreign_key "captain_units", "inboxes"
|
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
|
class Captain::UnitInbox < ApplicationRecord
|
||||||
self.table_name = 'captain_unit_inboxes'
|
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
|
- 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']}
|
#{'- 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
|
SYSTEM_PROMPT_MESSAGE
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -289,6 +296,58 @@ class Captain::Llm::SystemPromptsService
|
|||||||
• Do NOT mention page numbers anywhere in questions or answers
|
• Do NOT mention page numbers anywhere in questions or answers
|
||||||
PROMPT
|
PROMPT
|
||||||
end
|
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
|
# rubocop:enable Metrics/MethodLength
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -18,11 +18,13 @@ class Captain::Tools::SendSuiteImagesTool < Captain::Tools::BaseTool
|
|||||||
properties: {
|
properties: {
|
||||||
suite_category: {
|
suite_category: {
|
||||||
type: 'string',
|
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: {
|
suite_number: {
|
||||||
type: 'string',
|
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: {
|
limit: {
|
||||||
type: 'integer',
|
type: 'integer',
|
||||||
@ -211,12 +213,21 @@ class Captain::Tools::SendSuiteImagesTool < Captain::Tools::BaseTool
|
|||||||
category = normalize_filter(actual_params[:suite_category])
|
category = normalize_filter(actual_params[:suite_category])
|
||||||
suite_number = normalize_filter(actual_params[:suite_number])
|
suite_number = normalize_filter(actual_params[:suite_number])
|
||||||
|
|
||||||
|
# Sugerir buscar por categoria se buscou por número e não achou
|
||||||
|
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 = []
|
||||||
detail << "categoria #{category}" if category.present?
|
detail << "categoria #{category}" if category.present?
|
||||||
detail << "suíte #{suite_number}" if suite_number.present?
|
detail << "suíte #{suite_number}" if suite_number.present?
|
||||||
detail_text = detail.present? ? " para #{detail.join(' e ')}" : ''
|
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
|
end
|
||||||
|
|
||||||
def success_payload(selected_items, sent_count, actual_params)
|
def success_payload(selected_items, sent_count, actual_params)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user