chatwoot-develop/enterprise/app/services/captain/llm/jasmine_brain.rb
2026-01-19 19:26:23 -03:00

381 lines
14 KiB
Ruby
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

module Captain
module Llm
class JasmineBrain
Decision = Struct.new(:strategy, :tool_key, :reasoning, :tool_input, keyword_init: true)
def self.decide(assistant:, conversation:, message:, history:)
new(assistant, conversation, message, history).decide
end
def initialize(assistant, conversation, message, history)
@assistant = assistant
@conversation = conversation
@message = message.to_s
@history = history
@contact = conversation.contact
end
def decide
Rails.logger.info "[JasmineBrain] DECIDING for message: '#{@message}' | Contact: #{@contact.id} | Scenario: #{@conversation.active_scenario_key}"
# 1. Gate: Check if AI is disabled for this contact
return Decision.new(strategy: :skip_ai, reasoning: 'Contact has desligar_ia label') if contact_has_disabled_label?
# [FEATURE] React to thank you messages and emojis FIRST
# This takes priority over sticky scenarios
reaction_decision = check_thank_you_or_emoji
if reaction_decision
Rails.logger.info "[JasmineBrain] Short-circuiting to REACTION: #{reaction_decision.inspect}"
return reaction_decision
end
sticky_decision = sticky_decision_for_message
if sticky_decision
Rails.logger.info "[JasmineBrain] Sticky decision: #{sticky_decision.inspect}"
return sticky_decision
end
# [FEATURE] Dynamic Routing (Self-Service)
# Verifica se algum Agente (Scenario) tem palavras-chave (Gatilhos) que batem com a mensagem.
# Se sim, faz o roteamento imediato (Short-Circuit) sem consultar o LLM.
dynamic_decision = check_dynamic_triggers
return dynamic_decision if dynamic_decision
llm_decision = ask_brain_for_classification
if llm_decision
decision = Decision.new(
strategy: llm_decision['strategy'].to_s.downcase.to_sym,
tool_key: llm_decision['tool_key'],
reasoning: llm_decision['reasoning'],
tool_input: llm_decision['tool_input']
)
log_decision(decision)
return decision if decision.strategy == :execute_tool && decision.tool_key.present?
end
if feature_faq_enabled? && question_like?(@message) && !reservation_like?(@message)
log_decision(Decision.new(strategy: :execute_tool, tool_key: 'faq_lookup', reasoning: 'FAQ fallback', tool_input: { query: @message }))
return Decision.new(
strategy: :execute_tool,
tool_key: 'faq_lookup',
reasoning: 'Fallback FAQ lookup for general question with feature_faq enabled.',
tool_input: { query: @message }
)
end
decision = Decision.new(strategy: :direct, reasoning: llm_decision ? 'Direct fallback' : 'LLM Classification Failed')
log_decision(decision)
decision
rescue StandardError => e
Rails.logger.error "[JasmineBrain] Error in decision: #{e.message}"
Decision.new(strategy: :direct, reasoning: "Error: #{e.message}")
end
private
def contact_has_disabled_label?
@conversation.labels.exists?(name: 'desligar_ia')
rescue StandardError
false
end
def sticky_scenario_active?
return false unless @conversation.respond_to?(:active_scenario_key)
expires_at = @conversation.active_scenario_expires_at
if expires_at.present? && expires_at < Time.current
clear_sticky_session
return false
end
@conversation.active_scenario_key.present?
end
def exit_keyword?(message)
text = message.to_s.downcase
return false if text.blank?
exit_patterns = [
/\b(cancelar|sair|parar|desistir|reiniciar|comecar de novo|resetar)\b/i,
/\b(falar com (humano|atendente|pessoa))\b/i,
/\b(tchau|adeus|ate logo)\b/i
]
exit_patterns.any? { |pattern| text.match?(pattern) }
end
def clear_sticky_session
@conversation.update!(
active_scenario_key: nil,
active_scenario_expires_at: nil,
active_scenario_state: {}
)
# [DEEP CLEAN] Wipe any lingering Jasmine state or cached tool results
if @conversation.custom_attributes.present?
new_attrs = @conversation.custom_attributes.except('jasmine_state', 'last_availability')
@conversation.update!(custom_attributes: new_attrs)
end
Rails.logger.info "[JasmineBrain] Session DEEP CLEANED for Conversation ##{@conversation.id}"
rescue StandardError => e
Rails.logger.warn "[JasmineBrain] Failed to clear sticky session: #{e.message}"
end
def log_decision(decision)
payload = {
service: 'JasmineBrain',
conversation_id: @conversation&.id,
account_id: @conversation&.account_id,
decision_strategy: decision.strategy,
tool_key: decision.tool_key,
active_scenario_key: @conversation&.active_scenario_key,
scenario_stage: @conversation&.active_scenario_state&.dig('stage'),
message_length: @message.to_s.length,
timestamp: Time.current.iso8601
}
Rails.logger.info payload.to_json
rescue StandardError => e
Rails.logger.warn "[JasmineBrain] Failed to log decision: #{e.message}"
end
def ask_brain_for_classification
system_prompt = build_classification_prompt
model = @assistant.try(:llm_model).presence || 'gpt-4o-mini'
chat = RubyLLM.chat(model: model)
chat = chat.with_params(
response_format: { type: 'json_object' },
temperature: 0.1
)
chat.add_message({ role: 'system', content: system_prompt })
if @history.is_a?(Array)
@history.each do |msg|
content = msg[:content]
next if content.blank?
chat.add_message({ role: msg[:role], content: content })
end
end
raw_response = chat.ask(@message)
parse_json(raw_response)
end
def build_classification_prompt
# Carregamos as ferramentas e cenários dinamicamente do assistente
# Incluímos as ferramentas básicas e os "Cenários" (que são ScenarioDelegatorTool)
available_tools = @assistant.agent_tools(conversation: @conversation, user: nil)
tools_list = available_tools.map do |tool|
tool_name = tool.respond_to?(:name) ? tool.name : tool.class.name
tool_desc = tool.respond_to?(:description) ? tool.description : ''
tool_params = tool_parameters_schema_for_prompt(tool)
params_text = tool_params.present? ? "\n params_schema: #{tool_params.to_json}" : ''
"- #{tool_name}: #{tool_desc}#{params_text}"
end.join("\n")
<<~PROMPT
You are Jasmine, the Brain of the operation.
Your goal is to classify the user's intent based on their latest message and decide the action strategy.
AVAILABLE INTENTS (TOOLS):
#{tools_list}
- direct: For general conversation, doubts unrelated to specific tools, or if unsure.
IMPORTANT:
- If the user says "Oi", "Ola", "Tudo bem?", "Bom dia" -> Use "direct".
- If the user sends a THANK YOU message ("obrigado", "obrigada", "valeu", "agradeço", "muito obrigado", "agradecido") -> ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "❤️"}.
- If the user sends ONLY an emoji (🙏, 👍, ❤️, etc) -> ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "❤️"}.
- If the user wants to check availability or make a reservation but did NOT mention a specific suite name (Stilo, Alexa, Hidro, Master), you MUST use "direct" strategy and ask: "Qual suíte você prefere?" or "Para qual suíte?". Do NOT guess the suite.
- If the user's request matches one of the specialized departments (scenarios) above, use that tool.
- Do NOT trigger "escalar_humano" for greeting messages or simple questions.
- Only use "escalar_humano" if the user is explicitly requesting a human or is angry.
- If the list of AVAILABLE INTENTS (TOOLS) above is empty, ALWAYS use "direct".
- Only choose "execute_tool" when you can fill the required tool_input fields from the user's message or recent context.
- If required fields are missing, use "direct" and ask for the missing info.
- For scenario tools (consultar_*), set tool_input to {"pergunta_interna": "<resumo do pedido do cliente>"}.
- For faq_lookup, set tool_input to {"query": "<pergunta do cliente>"}.
Output MUST be a valid JSON object with:
{
"strategy": "execute_tool" OR "direct",
"tool_key": "THE_INTENT_KEY_IF_EXECUTE_TOOL_ELSE_NULL",
"tool_input": { "key": "value" } OR null,
"reasoning": "A brief explanation of why you chose this intent."
}
Example:
User: "Tem vaga agora?"
JSON: {"strategy": "execute_tool", "tool_key": "status_suites", "tool_input": null, "reasoning": "User asked about vacancy."}
Example:
User: "Muito obrigado!"
JSON: {"strategy": "execute_tool", "tool_key": "react_to_message", "tool_input": {"emoji": "❤️"}, "reasoning": "User sent a thank you message."}
PROMPT
end
def parse_json(response_obj)
content = response_obj.respond_to?(:content) ? response_obj.content : response_obj.to_s
# Attempt to clean code blocks if present (common with some LLMs)
clean_content = content.gsub(/^```json\s*|```$/, '').strip
JSON.parse(clean_content)
rescue JSON::ParserError
Rails.logger.warn "[JasmineBrain] Failed to parse JSON: #{content}"
nil
end
def sticky_decision_for_message
return nil unless sticky_scenario_active?
if exit_keyword?(@message)
Rails.logger.info '[JasmineBrain] EXIT KEYWORD DETECTED. Clearing session.'
clear_sticky_session
return Decision.new(
strategy: :direct,
reasoning: 'User requested reset/exit',
tool_input: nil
)
end
Decision.new(
strategy: :execute_tool,
tool_key: @conversation.active_scenario_key,
reasoning: 'Sticky scenario active',
tool_input: { pergunta_interna: @message }
)
end
def feature_faq_enabled?
@assistant.config['feature_faq'].to_s == 'true' || @assistant.config['feature_faq'] == true
end
def check_thank_you_or_emoji
raw_msg = @message
raw_msg = raw_msg.content if raw_msg.respond_to?(:content)
text = raw_msg.to_s.strip.downcase
# Patterns for thank you messages
thank_you_patterns = [
/\b(obrigad[oa]|obrigad[oa]s)\b/i,
/\b(valeu)\b/i,
/\b(agradeço|agradecid[oa])\b/i,
/\b(muito obrigad[oa])\b/i,
/\b(brigadão|brigadao|brigadinha)\b/i,
/\b(grat[oa]|gratidão|gratidao)\b/i
]
# Check if message is ONLY emoji(s)
only_emoji = text.gsub(/[\s\p{Emoji}]/u, '').empty? && text.match?(/\p{Emoji}/u)
if thank_you_patterns.any? { |pattern| text.match?(pattern) } || only_emoji
Rails.logger.info '[JasmineBrain] Detected thank you or emoji, triggering react_to_message'
return Decision.new(
strategy: :execute_tool,
tool_key: 'react_to_message',
reasoning: 'Thank you or emoji detected - short-circuit to react_to_message',
tool_input: { emoji: '' }
)
end
nil
end
def question_like?(message)
text = message.to_s.strip.downcase
return false if text.empty?
text.end_with?('?') ||
text.match?(/\A(qual|quanto|como|onde|quando|tem|possui|pode|faz|qual o|qual a)/)
end
def reservation_like?(message)
text = message.to_s.downcase
keywords = %w[
reserv agendar agendamento suite pernoite diaria
check-in checkin entrada saida horario amanha hoje
]
keywords.any? { |keyword| text.include?(keyword) }
end
def strong_reservation_intent?(message)
text = message.to_s.downcase
return false if text.blank?
# Padroes que indicam vontade explicita de reservar, nao apenas duvida
patterns = [
/quero reservar/i,
/gostaria de reservar/i,
/fazer (uma )?reserva/i,
/para o dia \d+/i,
/pro dia \d+/i,
/reservar para/i,
/tem vaga (para|pra)/i
]
patterns.any? { |pattern| text.match?(pattern) }
end
def check_dynamic_triggers
return nil if @message.blank?
# Carrega cenarios ativos que tenham palavras-chave definidas
scenarios = @assistant.scenarios.enabled.where.not(trigger_keywords: [nil, ''])
text = @message.downcase.strip
scenarios.each do |scenario|
# trigger_keywords eh text no banco, separado por virgula
keywords = scenario.trigger_keywords.to_s.split(',').map(&:strip).map(&:downcase).reject(&:blank?)
# Verifica se alguma palavra-chave esta contida na mensagem
match = keywords.find { |kw| text.include?(kw) }
next unless match
tool_key = "consultar_#{scenario.title.parameterize.underscore}"
Rails.logger.info "[JasmineBrain] Dynamic Trigger MATCH: '#{match}' -> Routing to #{scenario.title} (#{tool_key})"
return Decision.new(
strategy: :execute_tool,
tool_key: tool_key,
reasoning: "Dynamic Trigger matched keyword: '#{match}'",
tool_input: { pergunta_interna: @message }
)
end
nil
end
def tool_parameters_schema_for_prompt(tool)
return tool.tool_parameters_schema if tool.respond_to?(:tool_parameters_schema)
return nil unless tool.respond_to?(:parameters)
params = tool.parameters
return nil unless params.is_a?(Hash)
if params.values.all? { |param| param.respond_to?(:type) }
{
type: 'object',
properties: params.transform_values do |param|
{
type: param.type,
description: param.description
}.compact
end,
required: params.select { |_, param| param.required }.keys
}
else
params
end
end
end
end
end