118 lines
4.3 KiB
Ruby
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
|