chatwoot-develop/app/services/jasmine/brain_service.rb

285 lines
8.4 KiB
Ruby

class Jasmine::BrainService
# Default intent keywords for hotel/motel business
DEFAULT_INTENT_KEYWORDS = {
price_question: %w[preço valor quanto custa fica tabela promoção pernoite diária],
info_request: %w[como funciona detalhe explica suíte quarto tipo],
policy: %w[horário check-in check-out política regra pagamento cancelamento],
greeting: %w[oi olá bom dia boa tarde boa noite],
objection: %w[caro não sei preciso pensar depois conversar],
closing: %w[reservar agendar confirmar fechar quero sim pode],
general: [] # fallback
}.freeze
# Strategies for handling intents
RAG_MANDATORY = %i[price_question info_request policy].freeze
RAG_OPTIONAL = %i[objection general].freeze
RAG_PROHIBITED = %i[greeting closing].freeze
attr_reader :inbox, :conversation, :message, :config
def initialize(inbox:, conversation:, message:)
@inbox = inbox
@conversation = conversation
@message = message
@config = load_config
end
def respond
trigger_media_analysis if message.attachments.any?
llm_content = message.content_for_llm
intent = IntentDetector.new(llm_content, intent_keywords).detect
strategy = StrategyDecider.new(intent, jasmine_state).decide
rag_context = fetch_rag_if_needed(strategy, llm_content)
prompt = PromptAssembler.new(
config: config,
state: jasmine_state,
history: recent_history,
rag_context: rag_context,
current_message: llm_content
).assemble
response = call_llm(prompt)
StateUpdater.new(conversation, intent, rag_context.present?).update
log_decision(intent, strategy, rag_context)
response
rescue StandardError => e
Rails.logger.error "[Jasmine::Brain] Error: #{e.message}"
'Desculpe, tive um problema. Pode repetir?'
end
private
def load_config
inbox.jasmine_inbox_config || Jasmine::InboxConfig.new(
rag_distance_threshold: ENV.fetch('DEFAULT_JASMINE_DISTANCE_THRESHOLD', 0.35).to_f,
rag_max_results: 3,
model: ENV.fetch('JASMINE_LLM_MODEL', 'gpt-4o-mini'),
temperature: 0.7
)
end
def intent_keywords
custom = config.intent_keywords.presence || {}
DEFAULT_INTENT_KEYWORDS.merge(custom.deep_symbolize_keys)
end
def jasmine_state
conversation.custom_attributes&.dig('jasmine_state') || {}
end
def recent_history
msgs = conversation.messages
# Handle both ActiveRecord relations and arrays (for playground testing)
return [] unless msgs.respond_to?(:where)
msgs
.where(message_type: %w[incoming outgoing])
.order(created_at: :desc)
.limit(4) # 2 pairs of messages
.reverse
.map { |m| { role: m.message_type == 'incoming' ? 'user' : 'assistant', content: m.content } }
end
def fetch_rag_if_needed(strategy, query)
return nil if strategy == :no_rag
return nil if loop_protection_triggered?
results = Jasmine::SemanticSearchService.new(inbox).search(query, limit: config.rag_max_results)
return nil if results.empty?
results.map { |r| r[:content] || r.content }.join("\n\n---\n\n")
end
def loop_protection_triggered?
(jasmine_state['rag_queries_count'] || 0) > 5
end
def fallback_response
nil # Will trigger "vou verificar com a equipe"
end
def call_llm(prompt)
chat = RubyLLM.chat(model: config.model).with_temperature(config.temperature)
response = chat.ask(prompt)
response.content
end
def log_decision(intent, strategy, rag_context)
Rails.logger.info "[Jasmine::Brain] Intent: #{intent}, Strategy: #{strategy}, RAG: #{rag_context.present? ? 'yes' : 'no'}"
end
def trigger_media_analysis
Rails.logger.info "[Jasmine::Brain] Triggering Media Analysis for Message #{message.id}"
Jasmine::MediaAnalyzerService.new(message: message).perform
message.attachments.reload # CRITICAL: Ensure we see the new metadata
Rails.logger.info "[Jasmine::Brain] Media Analysis Completed for Message #{message.id}"
rescue StandardError => e
Rails.logger.error "[Jasmine::Brain] Media analysis failed: #{e.message}"
end
# =========================================
# COMPONENT: Intent Detector
# =========================================
class IntentDetector
attr_reader :text, :keywords
def initialize(text, keywords)
@text = text.to_s.downcase.strip
@keywords = keywords
end
def detect
# Check each intent type for keyword matches
keywords.each do |intent_type, words|
next if words.empty?
return intent_type if words.any? { |word| text.include?(word.downcase) }
end
:general # fallback
end
end
# =========================================
# COMPONENT: Strategy Decider
# =========================================
class StrategyDecider
attr_reader :intent, :state
def initialize(intent, state)
@intent = intent
@state = state
end
def decide
return :no_rag if RAG_PROHIBITED.include?(intent)
return :rag_required if RAG_MANDATORY.include?(intent)
:rag_optional
end
end
# =========================================
# COMPONENT: Prompt Assembler
# =========================================
class PromptAssembler
MAX_HISTORY_MESSAGES = 4 # 2 pairs
attr_reader :config, :state, :history, :rag_context, :current_message
def initialize(config:, state:, history:, rag_context:, current_message:)
@config = config
@state = state
@history = history.last(MAX_HISTORY_MESSAGES)
@rag_context = rag_context
@current_message = current_message
end
def assemble
parts = []
# System Prompt (identity, tone, rules)
parts << "[INSTRUÇÕES DO SISTEMA]\n#{config.system_prompt}" if config.system_prompt.present?
# Playbook SDR (sales script)
parts << "[PLAYBOOK SDR]\n#{config.playbook_prompt}" if config.playbook_prompt.present?
# Lead State
if state.present?
state_text = format_state(state)
parts << "[ESTADO DO LEAD]\n#{state_text}"
end
# RAG Context (SOURCE OF TRUTH)
if rag_context.present?
parts << <<~RAG
[CONTEXTO DA BASE DE CONHECIMENTO - FONTE DA VERDADE]
Use EXCLUSIVAMENTE as informações abaixo para responder.
NÃO invente ou complemente com conhecimento externo.
#{rag_context}
RAG
end
# History (limited)
if history.present?
history_text = history.map { |h| "#{h[:role] == 'user' ? 'Cliente' : 'Jasmine'}: #{h[:content]}" }.join("\n")
parts << "[HISTÓRICO RECENTE]\n#{history_text}"
end
# Current message
parts << "[MENSAGEM ATUAL DO CLIENTE]\n#{current_message}"
parts.join("\n\n")
end
private
def format_state(state)
lines = []
lines << "Etapa: #{state['stage']}" if state['stage']
lines << "Qualificado: #{state['qualified'] ? 'Sim' : 'Não'}" if state.key?('qualified')
if state['collected_info'].present?
state['collected_info'].each do |key, value|
lines << "#{key.capitalize}: #{value}"
end
end
lines.join("\n")
end
end
# =========================================
# COMPONENT: State Updater
# =========================================
class StateUpdater
MAX_STATE_SIZE = 4096 # 4KB
attr_reader :conversation, :intent, :rag_used
def initialize(conversation, intent, rag_used)
@conversation = conversation
@intent = intent
@rag_used = rag_used
end
def update
current_state = conversation.custom_attributes&.dig('jasmine_state') || {}
new_state = current_state.merge(
'last_intent' => intent.to_s,
'rag_queries_count' => (current_state['rag_queries_count'] || 0) + (rag_used ? 1 : 0),
'updated_at' => Time.current.iso8601
)
# Size protection
if new_state.to_json.bytesize > MAX_STATE_SIZE
Rails.logger.warn '[Jasmine::Brain] State too large, cleaning up'
new_state = {
'last_intent' => intent.to_s,
'rag_queries_count' => 0,
'updated_at' => Time.current.iso8601
}
end
conversation.update!(
custom_attributes: (conversation.custom_attributes || {}).merge('jasmine_state' => new_state)
)
end
def self.cleanup(conversation)
attrs = conversation.custom_attributes || {}
attrs.delete('jasmine_state')
conversation.update!(custom_attributes: attrs)
end
end
end