class Captain::Llm::AssistantChatService < Llm::BaseAiService include Captain::ChatHelper include Captain::ChatResponseHelper attr_reader :assistant, :conversation, :messages def initialize(assistant:, conversation: nil) super() Rails.logger.info "AssistantChatService: Initialized for Assistant #{assistant.id} / Conv #{conversation&.id}" @assistant = assistant @conversation = conversation @tools = build_tools @messages = [system_message, date_message] @response = '' # Prefer assistant model when set; otherwise keep configured default. @model = @assistant.llm_model.presence || @model end def generate_response(additional_message: nil, message_history: [], role: 'user') tool_output = nil # Skip brain decision layer if no conversation (playground mode) # Skip brain decision layer if no conversation (playground mode) # USER REQUEST: Bypass JasmineBrain temporarily for Live Chat too to match Playground behavior (Direct + Docs). # TODO: Re-enable JasmineBrain when tool configurations are ready. if @conversation.present? # 1. Brain Decision Layer (Jasmine) brain_decision = Captain::Llm::JasmineBrain.decide( assistant: @assistant, conversation: @conversation, message: additional_message, history: message_history ) # 2. Handle Skip Strategy (Stop AI) if brain_decision.strategy == :skip_ai Rails.logger.info "[Captain] Skipped AI response: #{brain_decision.reasoning}" return nil end # 3. Handle Tool Strategy if brain_decision.strategy == :execute_tool File.open(Rails.root.join('log/brain_debug.log'), 'a') { |f| f.puts "[#{Time.now}] BRAIN DECIDED: #{brain_decision.tool_key}" } inbox = @conversation.inbox return handle_handoff_request_action if brain_decision.tool_key == 'escalar_humano' runner_result = Captain::Tools::ToolRunner.run( assistant: @assistant, tool_key: brain_decision.tool_key, inbox: inbox, conversation: @conversation, additional_data: { message: additional_message, tool_input: brain_decision.tool_input } ) File.open(Rails.root.join('log/brain_debug.log'), 'a') { |f| f.puts "[#{Time.now}] RUNNER RESULT: #{runner_result.inspect}" } return { 'response' => runner_result[:body][:message] } if runner_result[:fallback] && runner_result.dig(:body, :message).present? if runner_result[:success] # Handle side-effects (e.g., labels for escalate_human) handle_tool_side_effects(brain_decision.tool_key, @conversation) # Normalize output for LLM tool_output = runner_result[:body] # Stop if tool was just a fire-and-forget webhook that suggests stopping return { 'response' => 'conversation_handoff', 'handoff_trigger' => 'user_request' } if brain_decision.tool_key == 'escalar_humano' elsif runner_result[:success] == false tool_failure = handle_tool_failure_action return tool_failure if tool_failure.present? end end # Inject Tool Output into System Context if available if tool_output @messages << { role: 'system', content: "DADO CONFIRMADO (FERRAMENTA): #{tool_output.to_json}" } end end context_pack = context_pack_message @messages << context_pack if context_pack.present? @messages += message_history # Inject Tool Output into System Context if available (for playground or post-tool) if tool_output @messages << { role: 'system', content: "DADO CONFIRMADO (FERRAMENTA): #{tool_output.to_json}" } end @messages << { role: role, content: additional_message } if additional_message.present? request_chat_completion end private def handle_handoff_request_action action = handoff_action('handoff_on_user_request_action', default: 'handoff') message = handoff_message('handoff_on_user_request_message') case action when 'handoff' return { 'response' => 'conversation_handoff', 'handoff_trigger' => 'user_request' } when 'reply' return { 'response' => message, 'handoff_trigger' => 'user_request' } when 'ignore' return { 'response' => fallback_handoff_message, 'handoff_trigger' => 'user_request' } end { 'response' => 'conversation_handoff', 'handoff_trigger' => 'user_request' } end def handle_tool_failure_action action = handoff_action('handoff_on_tool_failure_action', default: 'ignore') message = handoff_message('handoff_on_tool_failure_message') case action when 'handoff' { 'response' => 'conversation_handoff', 'handoff_trigger' => 'tool_failure' } when 'reply' { 'response' => message, 'handoff_trigger' => 'tool_failure' } when 'ignore' nil end end def handoff_action(key, default:) value = @assistant.config[key].to_s return value if %w[handoff reply ignore].include?(value) default end def handoff_message(key) message = @assistant.config[key].to_s.strip return message if message.present? fallback_handoff_message end def fallback_handoff_message I18n.t('captain.handoff_default_message', default: 'Desculpe, estou com dificuldades tecnicas no momento. Por favor, tente novamente em alguns instantes.') end def handle_tool_side_effects(tool_key, conversation) return unless tool_key == 'escalar_humano' conversation.add_labels(['desligar_ia']) conversation.custom_attributes['ai_disabled'] = true conversation.save! end def build_tools # Carregamos as ferramentas e cenários dinamicamente do assistente # Injetamos a conversa e o usuário para ferramentas contextuais. @assistant.agent_tools(conversation: @conversation, user: @user) end def system_message prompt = Captain::Llm::SystemPromptsService.assistant_response_generator( @assistant.name, @assistant.account.name, # Changed from @assistant.config['product_name'] @assistant.config ) prompt = Captain::MediaInterpolationService.new(account: @assistant.account).interpolate(prompt) prompt = append_reminder_tool_instruction(prompt) { role: 'system', content: prompt } end def append_reminder_tool_instruction(prompt) return prompt unless always_use_reminder_tool? <<~PROMPT.squish #{prompt} When a customer asks to schedule a reminder or to be reminded later, always call the reminder tool. PROMPT end def always_use_reminder_tool? return false if @conversation.blank? captain_inbox = CaptainInbox.find_by( inbox_id: @conversation.inbox_id, captain_assistant_id: @assistant.id ) captain_inbox&.always_use_reminder_tool? end def date_message { role: 'system', content: "Today is #{Time.zone.today.strftime('%A, %B %d, %Y')}." } end def context_pack_message return nil if @conversation.blank? insight = @conversation.latest_crm_insight insight_data = insight&.structured_data || {} contact = @conversation.contact contact_profile = contact&.additional_attributes || {} preferred_name = contact_profile['preferred_name'] name_confidence = contact_profile['name_confidence'] name_source = contact_profile['name_source'] summary_text = insight&.summary_text.to_s.strip summary_text = summary_text[0, 400] if summary_text.length > 400 content = build_context_pack( preferred_name: preferred_name, name_confidence: name_confidence, name_source: name_source, contact_profile: contact_profile, insight: insight, insight_data: insight_data, summary_text: summary_text ) Rails.logger.info("[Captain] Context pack size=#{content.length} conv_id=#{@conversation.id} insight_id=#{insight&.id || 'none'}") { role: 'system', content: content } end def build_context_pack(preferred_name:, name_confidence:, name_source:, contact_profile:, insight:, insight_data:, summary_text:) header = "[CONTEXT PACK]\n" guardrails = <<~GUARDRAILS GUARDRAILS: - Use o nome apenas se name_confidence >= 0.8. - Se nao houver nome confiavel, pergunte uma vez e siga sem nome. - Nao invente nome, preferencias ou dados que nao estejam no perfil/insights. GUARDRAILS contact_block = <<~CONTACT CONTACT_PROFILE: preferred_name: #{preferred_name.presence || 'desconhecido'} name_confidence: #{name_confidence.presence || '0'} name_source: #{name_source.presence || 'unknown'} preferences: #{format_list(contact_profile['preferences'])} frictions: #{format_list(contact_profile['frictions'])} contact_pattern: #{format_hash(contact_profile['contact_pattern'])} CONTACT insights_block = <<~INSIGHTS CONVERSATION_INSIGHTS (latest success): #{insight.present? ? "generated_at: #{insight.generated_at&.iso8601}" : 'sem insights ainda'} summary_text: #{summary_text.presence || 'sem resumo valido'} intent: #{format_value(insight_data['intent'])} urgency: #{format_value(insight_data['urgency'])} nba: #{format_hash(insight_data['nba'])} suggested_labels: #{format_list(insight_data['suggested_labels'])} INSIGHTS max_length = 1500 parts = [header, contact_block, insights_block, guardrails] combined = parts.join("\n").strip return combined if combined.length <= max_length trimmed_summary = summary_text[0, 200] insights_block = <<~INSIGHTS CONVERSATION_INSIGHTS (latest success): #{insight.present? ? "generated_at: #{insight.generated_at&.iso8601}" : 'sem insights ainda'} summary_text: #{trimmed_summary.presence || 'sem resumo valido'} intent: #{format_value(insight_data['intent'])} urgency: #{format_value(insight_data['urgency'])} nba: #{format_hash(insight_data['nba'])} suggested_labels: #{format_list(insight_data['suggested_labels'])} INSIGHTS combined = [header, contact_block, insights_block, guardrails].join("\n").strip return combined if combined.length <= max_length insights_block = <<~INSIGHTS CONVERSATION_INSIGHTS (latest success): #{insight.present? ? "generated_at: #{insight.generated_at&.iso8601}" : 'sem insights ainda'} intent: #{format_value(insight_data['intent'])} urgency: #{format_value(insight_data['urgency'])} nba: #{format_hash(insight_data['nba'])} suggested_labels: #{format_list(insight_data['suggested_labels'])} INSIGHTS combined = [header, contact_block, insights_block, guardrails].join("\n").strip return combined if combined.length <= max_length combined = [header, contact_block, guardrails].join("\n").strip combined[0, max_length] end def format_list(value) return 'nenhum' if value.blank? return value.join(', ') if value.is_a?(Array) value.to_s end def format_hash(value) return 'nenhum' if value.blank? return value.to_json if value.is_a?(Hash) value.to_s end def format_value(value) value.present? ? value.to_s : 'desconhecido' end def persist_message(message, message_type = 'assistant') # No need to implement end def feature_name 'assistant' end end