refactor: Extrai lógica para métodos auxiliares em MessageBuilder e Jasmine::PlaygroundController e ajusta formatação.

This commit is contained in:
Rodrigo Borba 2026-01-25 08:59:35 -03:00
parent b80d35a307
commit a392d81f06
15 changed files with 314 additions and 291 deletions

View File

@ -271,4 +271,4 @@ group :development, :test do
gem 'spring-watcher-listen'
end
gem "rqrcode", "~> 3.2"
gem 'rqrcode', '~> 3.2'

View File

@ -51,16 +51,14 @@ class Messages::MessageBuilder
file: uploaded_attachment
)
attachment.file_type = if uploaded_attachment.is_a?(String)
file_type_by_signed_id(
uploaded_attachment
)
else
file_type(uploaded_attachment&.content_type)
end
attachment.file_type = resolve_file_type(uploaded_attachment)
end
end
def resolve_file_type(attachment)
attachment.is_a?(String) ? file_type_by_signed_id(attachment) : file_type(attachment&.content_type)
end
def process_emails
return unless @conversation.inbox&.inbox_type == 'Email'
@ -222,11 +220,18 @@ class Messages::MessageBuilder
@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)
@automation_rule = extract_automation_rule
@in_reply_to = extract_in_reply_to
@items = content_attributes&.dig(:items)
end
def extract_automation_rule
content_attributes&.dig(:automation_rule_id)
end
def extract_in_reply_to
@params[:in_reply_to_id] || @params[:in_reply_to] || content_attributes&.dig(:in_reply_to)
end
end
Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder')

View File

@ -1,11 +1,5 @@
module Api
module V1
module Accounts
class FrequentQuestionsController < Api::V1::Accounts::BaseController
def index
@frequent_questions = Current.account.frequent_questions.order(occurrence_count: :desc).limit(50)
end
end
end
class Api::V1::Accounts::FrequentQuestionsController < Api::V1::Accounts::BaseController
def index
@frequent_questions = Current.account.frequent_questions.order(occurrence_count: :desc).limit(50)
end
end

View File

@ -1,43 +1,33 @@
module Api
module V1
module Accounts
module Inboxes
module Jasmine
class CollectionsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
class Api::V1::Accounts::Inboxes::Jasmine::CollectionsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
def index
# Returns collections linked to this inbox
collection_ids = @inbox.inbox_collections.pluck(:collection_id)
@collections = Current.account.jasmine_collections.where(id: collection_ids)
render json: @collections
end
def index
# Returns collections linked to this inbox
collection_ids = @inbox.inbox_collections.pluck(:collection_id)
@collections = Current.account.jasmine_collections.where(id: collection_ids)
render json: @collections
end
def create
# Link an existing collection to this inbox
collection = Current.account.jasmine_collections.find(params[:collection_id])
link = @inbox.inbox_collections.create!(
collection: collection,
priority: params[:priority] || 0
)
render json: link
end
def create
# Link an existing collection to this inbox
collection = Current.account.jasmine_collections.find(params[:collection_id])
link = @inbox.inbox_collections.create!(
collection: collection,
priority: params[:priority] || 0
)
render json: link
end
def destroy
# Unlink a collection from this inbox
link = @inbox.inbox_collections.find_by!(collection_id: params[:id])
link.destroy!
head :no_content
end
def destroy
# Unlink a collection from this inbox
link = @inbox.inbox_collections.find_by!(collection_id: params[:id])
link.destroy!
head :no_content
end
private
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
end
end
end
end
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
end

View File

@ -1,53 +1,43 @@
module Api
module V1
module Accounts
module Inboxes
module Jasmine
class ConfigsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :fetch_or_initialize_config
class Api::V1::Accounts::Inboxes::Jasmine::ConfigsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :fetch_or_initialize_config
def show
render json: @config
end
def show
render json: @config
end
def update
if @config.update(config_params)
render json: @config
else
render json: { error: @config.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
def fetch_or_initialize_config
@config = ::Jasmine::InboxConfig.find_or_initialize_by(
account: Current.account,
inbox: @inbox
)
end
def config_params
params.permit(
:is_enabled,
:system_prompt,
:playbook_prompt,
:model,
:temperature,
:rag_distance_threshold,
:rag_max_results,
:mode,
intent_keywords: {}
)
end
end
end
end
def update
if @config.update(config_params)
render json: @config
else
render json: { error: @config.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
def fetch_or_initialize_config
@config = ::Jasmine::InboxConfig.find_or_initialize_by(
account: Current.account,
inbox: @inbox
)
end
def config_params
params.permit(
:is_enabled,
:system_prompt,
:playbook_prompt,
:model,
:temperature,
:rag_distance_threshold,
:rag_max_results,
:mode,
intent_keywords: {}
)
end
end

View File

@ -1,85 +1,75 @@
module Api
module V1
module Accounts
module Inboxes
module Jasmine
class PlaygroundController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :fetch_config
class Api::V1::Accounts::Inboxes::Jasmine::PlaygroundController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :fetch_config
def test
message_content = params[:message]
return render json: { error: 'Message is required' }, status: :bad_request if message_content.blank?
return render json: { error: 'Jasmine is not enabled for this inbox' }, status: :unprocessable_entity unless @config&.is_enabled?
def test
return render json: { error: 'Message is required' }, status: :bad_request if params[:message].blank?
return render json: { error: 'Jasmine is not enabled for this inbox' }, status: :unprocessable_entity unless @config&.is_enabled?
# Create a mock message object for BrainService
mock_message = OpenStruct.new(
content: message_content,
inbox: @inbox,
conversation: mock_conversation
)
begin
response = generate_response(params[:message])
render json: build_success_payload(response)
rescue StandardError => e
handle_error(e)
end
end
begin
response = ::Jasmine::BrainService.new(
inbox: @inbox,
conversation: mock_conversation,
message: mock_message
).respond
private
render json: {
response: response,
debug: {
model: @config.model,
temperature: @config.temperature,
rag_threshold: @config.rag_distance_threshold
}
}
rescue StandardError => e
Rails.logger.error "[Jasmine::Playground] Error: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
render json: { error: e.message }, status: :internal_server_error
end
end
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
private
def fetch_config
@config = ::Jasmine::InboxConfig.find_by(
account: Current.account,
inbox: @inbox
)
end
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
def generate_response(content)
# Create a mock message object for BrainService
mock_message = OpenStruct.new(content: content, inbox: @inbox, conversation: mock_conversation)
::Jasmine::BrainService.new(inbox: @inbox, conversation: mock_conversation, message: mock_message).respond
end
def fetch_config
@config = ::Jasmine::InboxConfig.find_by(
account: Current.account,
inbox: @inbox
)
end
def build_success_payload(response)
{
response: response,
debug: {
model: @config.model,
temperature: @config.temperature,
rag_threshold: @config.rag_distance_threshold
}
}
end
def mock_conversation
# Create a minimal mock conversation for playground testing
@mock_conversation ||= begin
mock = OpenStruct.new(
id: 0,
account_id: Current.account.id,
inbox_id: @inbox.id,
custom_attributes: { 'jasmine_state' => {} }
)
# Mock messages method to return empty array that responds to query methods
def mock.messages
[]
end
# Mock update! to do nothing (playground doesn't need state persistence)
def mock.update!(**attrs)
# no-op for playground
end
mock
end
end
end
end
def handle_error(err)
Rails.logger.error "[Jasmine::Playground] Error: #{err.message}\n#{err.backtrace.first(5).join("\n")}"
render json: { error: err.message }, status: :internal_server_error
end
def mock_conversation
# Create a minimal mock conversation for playground testing
@mock_conversation ||= begin
mock = OpenStruct.new(
id: 0,
account_id: Current.account.id,
inbox_id: @inbox.id,
custom_attributes: { 'jasmine_state' => {} }
)
# Mock messages method to return empty array that responds to query methods
def mock.messages
[]
end
# Mock update! to do nothing (playground doesn't need state persistence)
def mock.update!(**attrs)
# no-op for playground
end
mock
end
end
end

View File

@ -1,93 +1,90 @@
module Api
module V1
module Accounts
module Inboxes
module Jasmine
class ToolsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
class Api::V1::Accounts::Inboxes::Jasmine::ToolsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
def index
configs = ::Jasmine::ToolConfig.where(inbox: @inbox).index_by(&:tool_key)
tools = ::Jasmine::ToolConfig::DEFINITIONS.map do |key, definition|
config = configs[key]
{
key: key,
name: definition[:name],
method: definition[:method].to_s.upcase,
url: definition[:url],
description: definition[:description],
is_enabled: config&.is_enabled || false,
plug_play_id: config&.plug_play_id,
plug_play_token: config&.plug_play_token.present? ? '****' : nil,
last_test: config ? {
at: config.last_tested_at,
status: config.last_test_status,
error: config.last_test_error,
duration: config.last_test_duration_ms
} : nil
}
end
def index
configs = ::Jasmine::ToolConfig.where(inbox: @inbox).index_by(&:tool_key)
render json: serialize_tools(configs)
end
render json: tools
end
def update
return render(json: { error: 'Invalid tool key' }, status: :bad_request) unless valid_tool_key?(params[:id])
def update
tool_key = params[:id] # Using :id from route as tool_key
unless ::Jasmine::ToolConfig::DEFINITIONS.key?(tool_key)
return render json: { error: 'Invalid tool key' }, status: :bad_request
end
config = find_config(params[:id])
update_config_attributes(config)
config = ::Jasmine::ToolConfig.find_or_initialize_by(
account: Current.account,
inbox: @inbox,
tool_key: tool_key
)
# Update attributes
config.is_enabled = params[:is_enabled]
config.plug_play_id = params[:plug_play_id]
# Secure token update: only update if present and not masked/empty
new_token = params[:plug_play_token]
if new_token.present? && new_token != '****'
config.plug_play_token = new_token
end
if config.save
render json: {
key: config.tool_key,
is_enabled: config.is_enabled,
plug_play_id: config.plug_play_id,
plug_play_token: '****'
}
else
render json: { error: config.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
def test
tool_key = params[:id]
begin
runner = ::Jasmine::ToolRunner.new(@inbox, tool_key)
result = runner.run
render json: result
rescue => e
Rails.logger.error "[JasmineTools] Test failed: #{e.try(:message)}"
render json: { success: false, error: "Server Error: #{e.try(:message)}" }, status: :internal_server_error
end
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
end
end
end
if config.save
render json: success_payload(config)
else
render(json: { error: config.errors.full_messages.join(', ') }, status: :unprocessable_entity)
end
end
def test
tool_key = params[:id]
begin
runner = ::Jasmine::ToolRunner.new(@inbox, tool_key)
result = runner.run
render json: result
rescue StandardError => e
Rails.logger.error "[JasmineTools] Test failed: #{e.try(:message)}"
render json: { success: false, error: "Server Error: #{e.try(:message)}" }, status: :internal_server_error
end
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
def valid_tool_key?(key)
::Jasmine::ToolConfig::DEFINITIONS.key?(key)
end
def find_config(key)
::Jasmine::ToolConfig.find_or_initialize_by(account: Current.account, inbox: @inbox, tool_key: key)
end
def update_config_attributes(config)
config.is_enabled = params[:is_enabled]
config.plug_play_id = params[:plug_play_id]
new_token = params[:plug_play_token]
config.plug_play_token = new_token if new_token.present? && new_token != '****'
end
def success_payload(config)
{ key: config.tool_key, is_enabled: config.is_enabled, plug_play_id: config.plug_play_id, plug_play_token: '****' }
end
def serialize_tools(configs)
::Jasmine::ToolConfig::DEFINITIONS.map do |key, definition|
build_tool_hash(key, definition, configs[key])
end
end
def build_tool_hash(key, definition, config)
{
key: key,
name: definition[:name],
method: definition[:method].to_s.upcase,
url: definition[:url],
description: definition[:description],
is_enabled: config&.is_enabled || false,
plug_play_id: config&.plug_play_id,
plug_play_token: config&.plug_play_token.present? ? '****' : nil,
last_test: serialize_last_test(config)
}
end
def serialize_last_test(config)
return unless config
{
at: config.last_tested_at,
status: config.last_test_status,
error: config.last_test_error,
duration: config.last_test_duration_ms
}
end
end

View File

@ -0,0 +1,47 @@
# Correção de Lint e Refatoração Backend
## Contexto
Ocorreram diversos erros de Lint (RuboCop) no backend, bloqueando o CI/CD ou a qualidade do código. Os principais arquivos afetados foram `MessageBuilder` e `ToolsController`.
## Ações Realizadas
### 1. Refatoração `MessageBuilder` (`app/builders/messages/message_builder.rb`)
- **Problema:** Classe muito longa (`Metrics/ClassLength`) e complexidade ciclomática alta.
- **Solução:**
- Lógica de `file_type` extraída para método auxiliar `resolve_file_type`.
- Métodos `extract_automation_rule`, `extract_in_reply_to` e `should_process_liquid?` compactados (one-liners).
- Redução geral de linhas e complexidade.
### 2. Refatoração `ToolsController` (`app/controllers/api/v1/accounts/inboxes/jasmine/tools_controller.rb`)
- **Problema:** Métodos longos (`Metrics/MethodLength`) e complexos (`Metrics/AbcSize`).
- **Solução:**
- Extração da validação de chave para `valid_tool_key?`.
- Extração da busca de config para `find_config`.
- Extração da atualização de atributos para `update_config_attributes`.
- Extração da serialização de resposta para `success_payload`, `build_tool_hash` e `serialize_last_test`.
- Correção de `Style/EmptyElse`.
### 3. Correções Gerais
- **Specs (`whatsapp_spec.rb`, `contact_spec.rb`):** Quebra de comentários longos para satisfazer `Layout/LineLength`.
- **StringLiterals:** Padronização para aspas simples.
- **ClassAndModuleChildren:** Formato compacto adotado onde necessário.
## Validação
- **RuboCop:** 0 ofensas.
- **RSpec:** 0 falhas nos testes unitários das classes afetadas.
## Como Reverter
Checkouts nos arquivos originais:
```bash
git checkout app/builders/messages/message_builder.rb
git checkout app/controllers/api/v1/accounts/inboxes/jasmine/tools_controller.rb
git checkout spec/models/channel/whatsapp_spec.rb
git checkout spec/models/contact_spec.rb
```

View File

@ -2,7 +2,7 @@ FactoryBot.define do
factory :jasmine_inbox_config, class: 'Jasmine::InboxConfig' do
association :account
association :inbox
name { "Test Jasmine" }
name { 'Test Jasmine' }
is_enabled { true }
end
@ -10,7 +10,7 @@ FactoryBot.define do
association :account
sequence(:name) { |n| "Collection #{n}" }
visibility { :private }
trait :private do
visibility { :private }
association :owner_inbox, factory: :inbox
@ -32,6 +32,6 @@ FactoryBot.define do
factory :jasmine_document, class: 'Jasmine::Document' do
association :account
association :collection, factory: :jasmine_collection
content { "Sample Content" }
content { 'Sample Content' }
end
end

View File

@ -22,7 +22,8 @@
# Indexes
#
# index_channel_whatsapp_on_phone_number (phone_number) UNIQUE
# index_channel_whatsapp_provider_connection (provider_connection) WHERE ((provider)::text = ANY ((ARRAY['baileys'::character varying, 'zapi'::character varying])::text[])) USING gin
# index_channel_whatsapp_provider_connection (provider_connection)
# WHERE ((provider)::text = ANY ((ARRAY['baileys'::character varying, 'zapi'::character varying])::text[])) USING gin
#
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb'

View File

@ -32,9 +32,11 @@
# index_contacts_on_company_id (company_id)
# index_contacts_on_lower_email_account_id (lower((email)::text), account_id)
# index_contacts_on_name_email_phone_number_identifier (name,email,phone_number,identifier) USING gin
# index_contacts_on_nonempty_fields (account_id,email,phone_number,identifier) WHERE (((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))
# index_contacts_on_nonempty_fields (account_id,email,phone_number,identifier)
# WHERE (((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))
# index_contacts_on_phone_number_and_account_id (phone_number,account_id)
# index_resolved_contact_account_id (account_id) WHERE (((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))
# index_resolved_contact_account_id (account_id)
# WHERE (((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))
# uniq_email_per_account_contact (email,account_id) UNIQUE
# uniq_identifier_per_account_contact (identifier,account_id) UNIQUE
#

View File

@ -32,11 +32,11 @@ RSpec.describe Jasmine::InboxCollection do
let!(:account) { create(:account) }
let!(:inbox) { create(:inbox, account: account) }
let!(:other_inbox) { create(:inbox, account: account) }
let!(:private_coll) { create(:jasmine_collection, :private, owner_inbox: inbox, account: account) }
let!(:shared_coll) { create(:jasmine_collection, :shared, account: account) }
context 'validations' do
context 'when linking collections' do
it 'allows linking private collection to owner inbox' do
link = build(:jasmine_inbox_collection, inbox: inbox, collection: private_coll, account: account)
expect(link).to be_valid
@ -45,7 +45,7 @@ RSpec.describe Jasmine::InboxCollection do
it 'prevents linking private collection to non-owner inbox' do
link = build(:jasmine_inbox_collection, inbox: other_inbox, collection: private_coll, account: account)
expect(link).not_to be_valid
expect(link.errors[:base]).to include("Private collections can only be linked to their owner inbox")
expect(link.errors[:base]).to include('Private collections can only be linked to their owner inbox')
end
it 'allows linking shared collection to any inbox in account' do

View File

@ -3,19 +3,15 @@ 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(: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, ...]
@ -24,6 +20,19 @@ RSpec.describe Jasmine::SemanticSearchService do
# Irrelevant: [0.0, 1.0, ...] -> Distance ~1.0
before do
# Ensure all `let` variables are initialized
account
inbox
config
collection_private
collection_shared
doc_private
doc_shared
# Link collections
create(:jasmine_inbox_collection, inbox: inbox, collection: collection_private, priority: 10, account: account)
create(:jasmine_inbox_collection, inbox: inbox, collection: collection_shared, priority: 0, account: account)
# 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))

View File

@ -1,22 +1,21 @@
# test_multi_agent_flow.rb
assistant = Captain::Assistant.find_by(name: 'Jasmine (Hotel Prime)')
account = assistant.account
def simulate_handoff(assistant, user_msg)
puts "\n=========================================================="
puts "USUÁRIO: #{user_msg}"
# Usando o motor Multi-Agente (V2) sem conversation real para o Playground
runner = Captain::Assistant::AgentRunnerService.new(assistant: assistant)
# Capturando o resultado do motor
result = runner.generate_response(message_history: [{ role: 'user', content: user_msg }])
puts "AGENTE QUE RESPONDEU: #{result['agent_name']}"
puts "RESPOSTA FINAL: #{result['response']}"
puts "RACIOCÍNIO: #{result['reasoning']}"
puts "SENTIMENTO: #{result['sentiment']}"
puts "=========================================================="
puts '=========================================================='
end
simulate_handoff(assistant, "Gostaria de agendar um quarto para amanhã às 22h")
simulate_handoff(assistant, 'Gostaria de agendar um quarto para amanhã às 22h')

View File

@ -12,7 +12,6 @@ if channel
puts 'Updating existing channel...'
channel.phone_number = phone_number
channel.provider_config['webhook_url'] = webhook_url
channel.save(validate: false)
else
puts 'Creating new channel...'
account = Account.first
@ -26,8 +25,8 @@ else
'webhook_url' => webhook_url
}
)
channel.save(validate: false)
end
channel.save(validate: false)
# Ensure inbox exists
inbox = Inbox.find_by(channel: channel)