chatwoot-develop/enterprise/app/services/captain/llm/jasmine_brain.rb
2026-01-14 10:24:10 -03:00

118 lines
4.3 KiB
Ruby

module Captain
module Llm
class JasmineBrain
Decision = Struct.new(:strategy, :tool_key, :reasoning, 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
# 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?
# 2. ASK THE BRAIN (LLM)
llm_decision = ask_brain_for_classification
# 3. Fallback safely if LLM fails
return Decision.new(strategy: :direct, reasoning: 'LLM Classification Failed') unless llm_decision
# 4. Return structured decision
Decision.new(
strategy: llm_decision['strategy'].to_sym,
tool_key: llm_decision['tool_key'],
reasoning: llm_decision['reasoning']
)
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?
@contact.labels.exists?(name: 'desligar_ia')
rescue StandardError
false
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|
chat.add_message({ role: msg[:role], content: msg[: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.description}"
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'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".
Output MUST be a valid JSON object with:
{
"strategy": "execute_tool" OR "direct",
"tool_key": "THE_INTENT_KEY_IF_EXECUTE_TOOL_ELSE_NULL",
"reasoning": "A brief explanation of why you chose this intent."
}
Example:
User: "Tem vaga agora?"
JSON: {"strategy": "execute_tool", "tool_key": "status_suites", "reasoning": "User asked about vacancy."}
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
end
end
end