module Captain module Llm class JasmineBrain Decision = Struct.new(:strategy, :tool_key, :reasoning, keyword_init: true) # Intents that trigger tools (SDR Skills) AVAILABLE_INTENTS = { 'status_suites' => 'User is asking about availability, status of rooms/suites, vacancies, or prices for specific periods.', 'maria_fotos' => 'User is explicitly asking to see photos, pictures, or visual references of the suites or the motel.' # 'escalar_humano' => 'User is asking to speak to a human, manager, attendant, or expressing frustration/anger.' (DISABLED TEMPORARILY) }.freeze 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 # Use Assistant's configured model or default to cheap model for thinking model = @assistant.try(:llm_model).presence || 'gpt-4o-mini' # Prefer cheap model for classification system_prompt = build_classification_prompt chat = RubyLLM.chat(model: model).with_params( response_format: { type: 'json_object' }, temperature: 0.1 # Low temperature for classification ) chat.add_message(role: 'system', content: system_prompt) # Include history for context if available @history.each { |msg| chat.add_message(role: msg[:role], content: msg[:content]) } if @history.is_a?(Array) raw_response = chat.ask(@message) parse_json(raw_response) end def build_classification_prompt # Filter available intents based on enabled tools for this assistant enabled_intents = AVAILABLE_INTENTS.select do |key, _| @assistant.tool_configs.exists?(tool_key: key, is_enabled: true) end tools_list = enabled_intents.map { |key, desc| "- #{key}: #{desc}" }.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". - 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