285 lines
8.4 KiB
Ruby
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
|