From 972fc5c67babb3f2f9435c501ae22a9abe49bfd0 Mon Sep 17 00:00:00 2001 From: Rodrigo Borba Date: Thu, 26 Feb 2026 23:04:28 -0300 Subject: [PATCH] feat(captain): improve suite photo search accuracy with AI guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../captain/reports/insights_controller.rb | 72 ++++++++ .../captain/reports/operational_controller.rb | 105 ++++++++++++ app/javascript/dashboard/store/index.js | 2 + .../dashboard/store/modules/captainReports.js | 111 ++++++++++++ .../dashboard/store/mutation-types.js | 6 + config/routes.rb | 6 + ...00_create_captain_conversation_insights.rb | 24 +++ ...6230001_create_captain_report_snapshots.rb | 18 ++ db/schema.rb | 49 +++++- .../captain/reports/daily_snapshot_job.rb | 91 ++++++++++ .../captain/reports/generate_insights_job.rb | 60 +++++++ .../captain/reports/weekly_insights_job.rb | 24 +++ .../models/captain/conversation_insight.rb | 63 +++++++ .../app/models/captain/report_snapshot.rb | 23 +++ enterprise/app/models/captain/unit_inbox.rb | 20 +++ .../llm/conversation_insight_service.rb | 160 ++++++++++++++++++ .../captain/llm/system_prompts_service.rb | 59 +++++++ .../captain/tools/send_suite_images_tool.rb | 17 +- 18 files changed, 906 insertions(+), 4 deletions(-) create mode 100644 app/controllers/api/v1/accounts/captain/reports/insights_controller.rb create mode 100644 app/controllers/api/v1/accounts/captain/reports/operational_controller.rb create mode 100644 app/javascript/dashboard/store/modules/captainReports.js create mode 100644 db/migrate/20260226230000_create_captain_conversation_insights.rb create mode 100644 db/migrate/20260226230001_create_captain_report_snapshots.rb create mode 100644 enterprise/app/jobs/captain/reports/daily_snapshot_job.rb create mode 100644 enterprise/app/jobs/captain/reports/generate_insights_job.rb create mode 100644 enterprise/app/jobs/captain/reports/weekly_insights_job.rb create mode 100644 enterprise/app/models/captain/conversation_insight.rb create mode 100644 enterprise/app/models/captain/report_snapshot.rb create mode 100644 enterprise/app/services/captain/llm/conversation_insight_service.rb diff --git a/app/controllers/api/v1/accounts/captain/reports/insights_controller.rb b/app/controllers/api/v1/accounts/captain/reports/insights_controller.rb new file mode 100644 index 000000000..6dc0783a3 --- /dev/null +++ b/app/controllers/api/v1/accounts/captain/reports/insights_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/captain/reports/operational_controller.rb b/app/controllers/api/v1/accounts/captain/reports/operational_controller.rb new file mode 100644 index 000000000..c790accdc --- /dev/null +++ b/app/controllers/api/v1/accounts/captain/reports/operational_controller.rb @@ -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 diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js index 1c46fb70f..21d937529 100755 --- a/app/javascript/dashboard/store/index.js +++ b/app/javascript/dashboard/store/index.js @@ -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, }); diff --git a/app/javascript/dashboard/store/modules/captainReports.js b/app/javascript/dashboard/store/modules/captainReports.js new file mode 100644 index 000000000..e82dbce81 --- /dev/null +++ b/app/javascript/dashboard/store/modules/captainReports.js @@ -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, +}; diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index 25df9517c..80fb98ea7 100644 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -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', }; diff --git a/config/routes.rb b/config/routes.rb index 4cb3a4190..f76ae4e4c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20260226230000_create_captain_conversation_insights.rb b/db/migrate/20260226230000_create_captain_conversation_insights.rb new file mode 100644 index 000000000..69ee64403 --- /dev/null +++ b/db/migrate/20260226230000_create_captain_conversation_insights.rb @@ -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 diff --git a/db/migrate/20260226230001_create_captain_report_snapshots.rb b/db/migrate/20260226230001_create_captain_report_snapshots.rb new file mode 100644 index 000000000..a1d448b96 --- /dev/null +++ b/db/migrate/20260226230001_create_captain_report_snapshots.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 41e8919de..db82e6965 100644 --- a/db/schema.rb +++ b/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" diff --git a/enterprise/app/jobs/captain/reports/daily_snapshot_job.rb b/enterprise/app/jobs/captain/reports/daily_snapshot_job.rb new file mode 100644 index 000000000..3dd2b2d91 --- /dev/null +++ b/enterprise/app/jobs/captain/reports/daily_snapshot_job.rb @@ -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 diff --git a/enterprise/app/jobs/captain/reports/generate_insights_job.rb b/enterprise/app/jobs/captain/reports/generate_insights_job.rb new file mode 100644 index 000000000..ecb7390b2 --- /dev/null +++ b/enterprise/app/jobs/captain/reports/generate_insights_job.rb @@ -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 diff --git a/enterprise/app/jobs/captain/reports/weekly_insights_job.rb b/enterprise/app/jobs/captain/reports/weekly_insights_job.rb new file mode 100644 index 000000000..d3828ab2c --- /dev/null +++ b/enterprise/app/jobs/captain/reports/weekly_insights_job.rb @@ -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 diff --git a/enterprise/app/models/captain/conversation_insight.rb b/enterprise/app/models/captain/conversation_insight.rb new file mode 100644 index 000000000..1f34f8f71 --- /dev/null +++ b/enterprise/app/models/captain/conversation_insight.rb @@ -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 diff --git a/enterprise/app/models/captain/report_snapshot.rb b/enterprise/app/models/captain/report_snapshot.rb new file mode 100644 index 000000000..262c9b6ef --- /dev/null +++ b/enterprise/app/models/captain/report_snapshot.rb @@ -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 diff --git a/enterprise/app/models/captain/unit_inbox.rb b/enterprise/app/models/captain/unit_inbox.rb index fc030477d..807d9f94c 100644 --- a/enterprise/app/models/captain/unit_inbox.rb +++ b/enterprise/app/models/captain/unit_inbox.rb @@ -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' diff --git a/enterprise/app/services/captain/llm/conversation_insight_service.rb b/enterprise/app/services/captain/llm/conversation_insight_service.rb new file mode 100644 index 000000000..baede0360 --- /dev/null +++ b/enterprise/app/services/captain/llm/conversation_insight_service.rb @@ -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 diff --git a/enterprise/app/services/captain/llm/system_prompts_service.rb b/enterprise/app/services/captain/llm/system_prompts_service.rb index b48808ab7..7b8e78f87 100644 --- a/enterprise/app/services/captain/llm/system_prompts_service.rb +++ b/enterprise/app/services/captain/llm/system_prompts_service.rb @@ -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 diff --git a/enterprise/app/services/captain/tools/send_suite_images_tool.rb b/enterprise/app/services/captain/tools/send_suite_images_tool.rb index 5b98ad31a..6083c1df1 100644 --- a/enterprise/app/services/captain/tools/send_suite_images_tool.rb +++ b/enterprise/app/services/captain/tools/send_suite_images_tool.rb @@ -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)