refactor: Otimiza a inicialização de atributos em MessageBuilder e simplifica a declaração de módulos em diversos controladores Captain, além de atualizar a configuração de exceções para testes e o Bundler.

This commit is contained in:
Rodrigo Borba 2026-01-25 08:39:23 -03:00
parent c76c8d5c50
commit b80d35a307
14 changed files with 332 additions and 363 deletions

View File

@ -1183,4 +1183,4 @@ RUBY VERSION
ruby 3.4.4p34
BUNDLED WITH
4.0.4
2.5.5

View File

@ -6,19 +6,13 @@ class Messages::MessageBuilder
attr_reader :message
def initialize(user, conversation, params)
@params = params
@private = params[:private] || false
@conversation = conversation
@user = user
@conversation = conversation
@account = conversation.account
@message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments]
@automation_rule = content_attributes&.dig(:automation_rule_id)
@params = params
return unless params.instance_of?(ActionController::Parameters)
# Try to find in_reply_to in params (top level) or content_attributes
@in_reply_to = params[:in_reply_to_id] || params[:in_reply_to] || content_attributes&.dig(:in_reply_to)
@items = content_attributes&.dig(:items)
init_message_attributes
end
def perform
@ -223,6 +217,16 @@ class Messages::MessageBuilder
'agent' => UserDrop.new(sender)
})
end
def init_message_attributes
@private = @params[:private] || false
@message_type = @params[:message_type] || 'outgoing'
@attachments = @params[:attachments]
@automation_rule = content_attributes&.dig(:automation_rule_id)
# Try to find in_reply_to in params (top level) or content_attributes
@in_reply_to = @params[:in_reply_to_id] || @params[:in_reply_to] || content_attributes&.dig(:in_reply_to)
@items = content_attributes&.dig(:items)
end
end
Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder')

View File

@ -1,8 +1,4 @@
module Api
module V1
module Accounts
module Captain
class AssistantsController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController
before_action :fetch_assistant, only: [:show, :update, :destroy, :playground, :test_webhook]
def index
@ -66,7 +62,3 @@ module Api
)
end
end
end
end
end
end

View File

@ -11,28 +11,12 @@ class Api::V1::Accounts::Captain::PricingsController < Api::V1::Accounts::BaseCo
end
def create
# Handle multiple inboxes (cloning/distributing the price)
inbox_ids_param = params[:pricing][:inbox_ids]
if inbox_ids_param.present? && inbox_ids_param.is_a?(Array)
last_pricing = nil
ActiveRecord::Base.transaction do
inbox_ids_param.each do |iid|
# Create for each inbox. Merge overwrites default params.
# Convert iid to integer just in case
pricing = current_account.captain_pricings.new(pricing_params)
pricing.inbox_id = iid.to_i
pricing.save!
last_pricing = pricing
end
end
render json: last_pricing
if inbox_ids_param.is_a?(Array) && inbox_ids_param.present?
render json: create_for_multiple_inboxes(inbox_ids_param)
else
# Create Global (inbox_id: nil) if no specific inbox selected
@pricing = current_account.captain_pricings.new(pricing_params)
@pricing.inbox_id = nil
@pricing.save!
render json: @pricing
render json: create_single_pricing
end
rescue StandardError => e
Rails.logger.error "Error creating pricing: #{e.message}"
@ -69,7 +53,7 @@ class Api::V1::Accounts::Captain::PricingsController < Api::V1::Accounts::BaseCo
# Filter by inbox if provided (returns Specific Inbox + Global rules)
@pricings = @pricings.where(inbox_id: [params[:inbox_id], nil]) if params[:inbox_id].present?
return unless params[:query].present?
return if params[:query].blank?
# Fuzzy search using ILIKE for case-insensitive matching
@pricings = @pricings.left_outer_joins(:captain_brand).where(
@ -81,4 +65,24 @@ class Api::V1::Accounts::Captain::PricingsController < Api::V1::Accounts::BaseCo
def pricing_params
params.require(:pricing).permit(:captain_brand_id, :day_range, :suite_category, :duration, :price)
end
def create_for_multiple_inboxes(inbox_ids)
last_pricing = nil
ActiveRecord::Base.transaction do
inbox_ids.each do |iid|
pricing = current_account.captain_pricings.new(pricing_params)
pricing.inbox_id = iid.to_i
pricing.save!
last_pricing = pricing
end
end
last_pricing
end
def create_single_pricing
@pricing = current_account.captain_pricings.new(pricing_params)
@pricing.inbox_id = nil
@pricing.save!
@pricing
end
end

View File

@ -1,8 +1,4 @@
module Api
module V1
module Accounts
module Captain
class ScenariosController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::BaseController
before_action :fetch_assistant
before_action :fetch_scenario, only: [:show, :update, :destroy]
@ -65,7 +61,3 @@ module Api
)
end
end
end
end
end
end

View File

@ -1,8 +1,4 @@
module Api
module V1
module Accounts
module Captain
class ToolsController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Captain::ToolsController < Api::V1::Accounts::BaseController
before_action :fetch_assistant
NATIVE_TOOLS = [
@ -14,7 +10,7 @@ module Api
{ key: 'list_reservations', name: 'List Reservations', description: 'Lista reservas anteriores do cliente.' },
{ key: 'status_suites', name: 'Status Suites', description: 'Verifica o status atual de ocupação das suítes.' },
{ key: 'suite_watchdog', name: 'Suite Watchdog', description: 'Monitoramento automático de status de suítes.' }
]
].freeze
def index
tools = NATIVE_TOOLS.map do |tool|
@ -67,7 +63,3 @@ module Api
)
end
end
end
end
end
end

View File

@ -1,9 +1,4 @@
module Api
module V1
module Accounts
module Captain
module Units
class ReservationsSyncController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Captain::Units::ReservationsSyncController < Api::V1::Accounts::BaseController
def create
unit = Current.account.captain_units.find(params[:unit_id])
::Captain::Reservations::SyncService.new(unit).perform
@ -14,8 +9,3 @@ module Api
render_error(e.message)
end
end
end
end
end
end
end

View File

@ -1,8 +1,4 @@
module Api
module V1
module Accounts
module Captain
class UnitsController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Captain::UnitsController < Api::V1::Accounts::BaseController
def index
@units = Current.account.captain_units
end
@ -59,7 +55,3 @@ module Api
)
end
end
end
end
end
end

View File

@ -19,25 +19,7 @@ class Api::V1::Accounts::Conversations::CrmInsightsController < Api::V1::Account
def serialize_insight(insight)
return nil if insight.blank?
{
id: insight.id,
conversation_id: insight.conversation_id,
account_id: insight.account_id,
contact_id: insight.contact_id,
summary_text: insight.summary_text,
structured_data: insight.structured_data,
contact_sessions_count: insight.contact_sessions_count,
last_contact_at: insight.last_contact_at,
updated_at: insight.updated_at,
generated_at: insight.generated_at,
range_from_message_id: insight.range_from_message_id,
range_to_message_id: insight.range_to_message_id,
status: insight.status,
error_message: insight.error_message,
schema_version: insight.schema_version,
model: insight.model,
confidence: insight.confidence
}
insight_attributes(insight)
end
def insights_payload
@ -68,4 +50,26 @@ class Api::V1::Accounts::Conversations::CrmInsightsController < Api::V1::Account
meta
end
def insight_attributes(insight)
{
id: insight.id,
conversation_id: insight.conversation_id,
account_id: insight.account_id,
contact_id: insight.contact_id,
summary_text: insight.summary_text,
structured_data: insight.structured_data,
contact_sessions_count: insight.contact_sessions_count,
last_contact_at: insight.last_contact_at,
updated_at: insight.updated_at,
generated_at: insight.generated_at,
range_from_message_id: insight.range_from_message_id,
range_to_message_id: insight.range_to_message_id,
status: insight.status,
error_message: insight.error_message,
schema_version: insight.schema_version,
model: insight.model,
confidence: insight.confidence
}
end
end

View File

@ -88,7 +88,7 @@ class User < ApplicationRecord
accepts_nested_attributes_for :account_users
has_many :assigned_conversations, foreign_key: 'assignee_id', class_name: 'Conversation', dependent: :nullify, inverse_of: :assignee
alias_attribute :conversations, :assigned_conversations
alias conversations assigned_conversations
has_many :csat_survey_responses, foreign_key: 'assigned_agent_id', dependent: :nullify, inverse_of: :assigned_agent
has_many :conversation_participants, dependent: :destroy_async
has_many :participating_conversations, through: :conversation_participants, source: :conversation

View File

@ -29,7 +29,7 @@ Rails.application.configure do
config.cache_store = :null_store
# Raise exceptions instead of rendering exception templates.
config.action_dispatch.show_exceptions = true
config.action_dispatch.show_exceptions = :all
# Disable request forgery protection in test environment.
config.action_controller.allow_forgery_protection = false

View File

@ -1,19 +1,21 @@
require 'rails_helper'
RSpec.describe Jasmine::SemanticSearchService do
subject { described_class.new(inbox) }
let!(:account) { create(:account) }
let!(:inbox) { create(:inbox, account: account) }
let!(:config) { create(:jasmine_inbox_config, inbox: inbox, account: account, is_enabled: true) }
let!(:collection_private) { create(:jasmine_collection, name: "Private", visibility: :private, owner_inbox: inbox, account: account) }
let!(:collection_shared) { create(:jasmine_collection, name: "Shared", visibility: :shared, account: account) }
let!(:collection_private) { create(:jasmine_collection, name: 'Private', visibility: :private, owner_inbox: inbox, account: account) }
let!(:collection_shared) { create(:jasmine_collection, name: 'Shared', visibility: :shared, account: account) }
# Link collections: Private (High Priority), Shared (Low Priority)
let!(:link_private) { create(:jasmine_inbox_collection, inbox: inbox, collection: collection_private, priority: 10, account: account) }
let!(:link_shared) { create(:jasmine_inbox_collection, inbox: inbox, collection: collection_shared, priority: 0, account: account) }
let!(:doc_private) { create(:jasmine_document, collection: collection_private, content: "Private Secret", account: account) }
let!(:doc_shared) { create(:jasmine_document, collection: collection_shared, content: "Shared Knowledge", account: account) }
let!(:doc_private) { create(:jasmine_document, collection: collection_private, content: 'Private Secret', account: account) }
let!(:doc_shared) { create(:jasmine_document, collection: collection_shared, content: 'Shared Knowledge', account: account) }
# Mock Embedding Service behavior by creating chunks directly with known vectors
# Query Vector: [1.0, 0.0, ...]
@ -23,8 +25,8 @@ RSpec.describe Jasmine::SemanticSearchService do
before do
# Create chunks manually to bypass job/api dependency
create_chunk(doc_private, [0.9] + [0.0]*1535)
create_chunk(doc_shared, [0.8] + [0.0]*1535)
create_chunk(doc_private, [0.9] + ([0.0]*1535))
create_chunk(doc_shared, [0.8] + ([0.0]*1535))
end
def create_chunk(doc, vec)
@ -37,20 +39,18 @@ RSpec.describe Jasmine::SemanticSearchService do
)
end
subject { described_class.new(inbox) }
describe '#search' do
it 'returns results from enabled collections' do
# Mock the embedding generation for the query
allow(RubyLLM).to receive(:embed).and_return(OpenStruct.new(vectors: [[1.0] + [0.0]*1535]))
allow(RubyLLM).to receive(:embed).and_return(OpenStruct.new(vectors: [[1.0] + ([0.0]*1535)]))
results = subject.search("Query")
results = subject.search('Query')
expect(results.size).to be >= 2
expect(results.first.content).to eq("Private Secret")
expect(results.first.content).to eq('Private Secret')
end
it 'respects priority (waterfall)' do
allow(RubyLLM).to receive(:embed).and_return(OpenStruct.new(vectors: [[1.0] + [0.0]*1535]))
allow(RubyLLM).to receive(:embed).and_return(OpenStruct.new(vectors: [[1.0] + ([0.0]*1535)]))
# Even if shared has a PERFECT match, if Private has a "Good Enough" match (below threshold),
# does the waterfall prioritize Private?
@ -61,19 +61,19 @@ RSpec.describe Jasmine::SemanticSearchService do
# Let's limit the service to 1 result to test waterfall
# stub_const("Jasmine::SemanticSearchService::FINAL_LIMIT", 1) # Removed as service uses arg
results = subject.search("Query", limit: 1)
results = subject.search('Query', limit: 1)
expect(results.size).to eq(1)
expect(results.first.content).to eq("Private Secret") # Should be from High Priority collection
expect(results.first.content).to eq('Private Secret') # Should be from High Priority collection
end
it 'filters out results above threshold' do
# Create a bad chunk
bad_doc = create(:jasmine_document, collection: collection_private, account: account)
create_chunk(bad_doc, [0.0] + [1.0]*1535) # Orthogonal/Opposite
create_chunk(bad_doc, [0.0] + ([1.0] * 1535)) # Orthogonal/Opposite
allow(RubyLLM).to receive(:embed).and_return(OpenStruct.new(vectors: [[1.0] + [0.0]*1535]))
allow(RubyLLM).to receive(:embed).and_return(OpenStruct.new(vectors: [[1.0] + ([0.0] * 1535)]))
results = subject.search("Query")
results = subject.search('Query')
expect(results.map(&:content)).not_to include(bad_doc.content)
end
end

View File

@ -11,7 +11,6 @@ def test_msg(assistant, msg)
puts " (Raciocínio: #{res['reasoning']})"
end
test_msg(assistant, "Oi, qual o valor da pernoite na Alexa?")
test_msg(assistant, "Quero reservar para amanhã 22h")
test_msg(assistant, "ESTOU COM MUITA RAIVA!")
test_msg(assistant, 'Oi, qual o valor da pernoite na Alexa?')
test_msg(assistant, 'Quero reservar para amanhã 22h')
test_msg(assistant, 'ESTOU COM MUITA RAIVA!')

View File

@ -3,7 +3,7 @@
assistant = Captain::Assistant.find_by(name: 'Jasmine (Hotel Prime)')
unless assistant
puts "Assistente Jasmine não encontrada. Execute o seed primeiro."
puts 'Assistente Jasmine não encontrada. Execute o seed primeiro.'
exit
end
@ -18,7 +18,7 @@ def simulate_chat(assistant, message)
response = runner.generate_response(message_history: history)
puts "--- JASMINE RESPONDE:"
puts '--- JASMINE RESPONDE:'
puts "DEBUG: #{response.inspect}"
puts "Pensamento: #{response['reasoning']}"
puts "Agente Atual: #{response['agent_name'] || 'Orquestrador'}"
@ -27,8 +27,8 @@ def simulate_chat(assistant, message)
end
# Casos de Teste
simulate_chat(assistant, "Oi, quanto custa a pernoite na suite Alexa?")
simulate_chat(assistant, "Quero reservar para sabado que vem às 20h.")
simulate_chat(assistant, "Tem suite livre agora? To chegando em 10 minutos.")
simulate_chat(assistant, "Pode me mandar fotos da suite com hidro?")
simulate_chat(assistant, "ESTOU MUITO IRRITADO COM A DEMORA!") # Teste de sentimento
simulate_chat(assistant, 'Oi, quanto custa a pernoite na suite Alexa?')
simulate_chat(assistant, 'Quero reservar para sabado que vem às 20h.')
simulate_chat(assistant, 'Tem suite livre agora? To chegando em 10 minutos.')
simulate_chat(assistant, 'Pode me mandar fotos da suite com hidro?')
simulate_chat(assistant, 'ESTOU MUITO IRRITADO COM A DEMORA!') # Teste de sentimento