374 lines
14 KiB
Ruby
374 lines
14 KiB
Ruby
# enterprise/lib/captain/tools/scenario_delegator_tool.rb
|
|
class Captain::Tools::ScenarioDelegatorTool < Captain::Tools::BasePublicTool
|
|
attr_reader :scenario
|
|
|
|
def initialize(scenario, user: nil, conversation: nil)
|
|
@scenario = scenario
|
|
super(@scenario.assistant, user: user, conversation: conversation)
|
|
end
|
|
|
|
def name
|
|
"consultar_#{@scenario.title.parameterize.underscore}"
|
|
end
|
|
|
|
def description
|
|
"Consulta o departamento especializado: #{@scenario.description}. Use esta ferramenta para obter informações ou realizar ações sobre este assunto."
|
|
end
|
|
|
|
param :pergunta_interna, type: 'string', desc: 'A pergunta ou instrução detalhada que você quer enviar para este departamento.'
|
|
|
|
def perform(_tool_context, pergunta_interna: nil, **kwargs)
|
|
merged_args = kwargs.merge(pergunta_interna: pergunta_interna).with_indifferent_access
|
|
pergunta_interna = merged_args[:pergunta_interna] || merged_args['pergunta_interna']
|
|
|
|
if pergunta_interna.blank?
|
|
# Fallback: Se a IA chamou a ferramenta sem argumentos, tentamos pegar a última mensagem do usuário
|
|
last_message = @conversation&.messages&.incoming&.last&.content
|
|
pergunta_interna = last_message.presence || "Iniciar atendimento do cenário #{@scenario.title}"
|
|
Rails.logger.warn "[ScenarioDelegatorTool] Warning: 'pergunta_interna' is missing. Using fallback: '#{pergunta_interna}'"
|
|
end
|
|
|
|
ensure_sticky_session!
|
|
|
|
# [FIX] Inject Context: The sub-agent needs previous messages to understand "Yes" or "I want that".
|
|
# We fetch the last 6 messages (excluding the very last valid user message if we just grabbed it)
|
|
reset_detected = false
|
|
if @conversation
|
|
recent_history = @conversation.messages.where(private: false).order(created_at: :desc).limit(10).reverse
|
|
|
|
# [RESET LOGIC] Truncate history if a reset command is found
|
|
reset_index = recent_history.rindex { |m| m.content.to_s.downcase.match?(/\b(reiniciar|resetar|comecar de novo)\b/i) }
|
|
if reset_index
|
|
# Keep only messages AFTER the reset command
|
|
recent_history = recent_history[(reset_index + 1)..] || []
|
|
reset_detected = true
|
|
end
|
|
|
|
# Format: "Rodrigo: Quero suite stilo\nJasmine: Disponivel, quer?\n..."
|
|
history_text = recent_history.map { |m| "#{m.sender&.name}: #{m.content}" }.join("\n")
|
|
|
|
# [CRITICAL] Always append the very last raw user message to ensure 'pergunta_interna' isn't just a vague summary.
|
|
last_user_msg = @conversation.messages.incoming.last&.content
|
|
if last_user_msg.present? && history_text.exclude?(last_user_msg)
|
|
# Fallback mostly, as history_text likely has it.
|
|
history_text += "\nCliente (Última Mensagem): #{last_user_msg}"
|
|
end
|
|
|
|
# [FORCE RESET] If reset detected in history, force clear session NOW to prevent ensure_sticky_session! from keeping old data
|
|
if reset_detected
|
|
Rails.logger.info '[ScenarioDelegatorTool] Reset detected in history. Forcing new session state.'
|
|
clear_sticky_session
|
|
@conversation.reload # Reload to ensure we have clean state
|
|
ensure_sticky_session! # Create fresh blank session
|
|
end
|
|
|
|
contato_contexto = build_contact_context
|
|
# [CRITICAL] If reset detected, DO NOT inject previous sticky state like 'last_availability'
|
|
state_context = reset_detected ? '{}' : build_state_context(pergunta_interna)
|
|
|
|
pergunta_interna = "Historico Recente:\n#{history_text}\n\n#{contato_contexto}\n\nEstado Atual (JSON):\n#{state_context}\n\nInstrucao/Pergunta da Jasmine: #{pergunta_interna}"
|
|
# Truncate to avoid context limit issues if massive
|
|
pergunta_interna = pergunta_interna.last(10_000)
|
|
end
|
|
|
|
# Instanciamos o agente do cenário, que já carrega suas próprias ferramentas (custom tools, etc)
|
|
agent = @scenario.agent(user: @user, conversation: @conversation)
|
|
|
|
# Usamos o Runner padrão (Agents gem) para permitir o loop de Pensamento/Ação
|
|
# Isso permite que este sub-agente decida se precisa chamar ferramentas ou apenas responder
|
|
Rails.logger.info "[ScenarioDelegatorTool] Iniciando sub-agente: #{@scenario.title}"
|
|
Rails.logger.info "[ScenarioDelegatorTool] Contexto Injetado. Tamanho: #{pergunta_interna.length} chars"
|
|
Rails.logger.info "[ScenarioDelegatorTool] Ferramentas do Agente (#{@scenario.title}): #{agent.tools.map(&:name)}"
|
|
|
|
runner = Agents::Runner.with_agents(agent)
|
|
|
|
result = runner.run(pergunta_interna, max_turns: 10)
|
|
|
|
Rails.logger.info "[ScenarioDelegatorTool] Sub-agente (#{@scenario.title}) finished. Success: #{!result.failed?}, Output: #{result.output.inspect}"
|
|
|
|
if result.failed? || result.output.nil?
|
|
Rails.logger.info "[ScenarioDelegatorTool] Falha no sub-agente (#{@scenario.title}):"
|
|
# Agents::RunResult names: failed?, error, messages
|
|
Rails.logger.info " - Error Type: #{result.error&.class}"
|
|
Rails.logger.info " - Error Message: #{result.error}"
|
|
Rails.logger.info " - Last Messages: #{result.messages.last(3).map { |m| m.slice(:role, :content, :tool_calls) }.inspect}"
|
|
|
|
fallback_response = attempt_fallback(pergunta_interna)
|
|
return fallback_response if fallback_response.present?
|
|
|
|
return "O departamento #{@scenario.title} encontrou um erro: #{result.error || 'sem resposta clara'}."
|
|
end
|
|
|
|
# Se o output for nulo ou vazio mas nao falhou (improvavel), garante resposta
|
|
final_output = result.output.is_a?(Hash) ? (result.output['response'] || result.output.to_s) : result.output.to_s
|
|
if final_output.blank?
|
|
Rails.logger.warn "[ScenarioDelegatorTool] Output em branco para #{@scenario.title}. Usando fallback de identificacao."
|
|
fallback_response = attempt_fallback(pergunta_interna)
|
|
return fallback_response if fallback_response.present?
|
|
|
|
return "O departamento #{@scenario.title} concluiu mas nao enviou resposta."
|
|
end
|
|
|
|
update_sticky_session_state(final_output)
|
|
log_scenario_run(final_output)
|
|
clear_sticky_session if completion_signal?(final_output)
|
|
|
|
final_output
|
|
|
|
rescue StandardError => e
|
|
Rails.logger.error "[ScenarioDelegatorTool] ERRO CRÍTICO no sub-agente #{@scenario.title}: #{e.message}"
|
|
if e.respond_to?(:record) && e.record
|
|
Rails.logger.error "[ScenarioDelegatorTool] Invalid Record Class: #{e.record.class.name}"
|
|
Rails.logger.error "[ScenarioDelegatorTool] Invalid Record Errors: #{e.record.errors.full_messages.inspect}"
|
|
Rails.logger.error "[ScenarioDelegatorTool] Invalid Record Attributes: #{e.record.attributes.inspect}"
|
|
end
|
|
Rails.logger.error "[ScenarioDelegatorTool] Backtrace:\n#{e.backtrace.first(15).join("\n")}"
|
|
"Erro técnico ao consultar o departamento #{@scenario.title}: #{e.message}"
|
|
end
|
|
|
|
private
|
|
|
|
def ensure_sticky_session!
|
|
return unless @conversation.respond_to?(:active_scenario_key)
|
|
|
|
now = Time.current
|
|
expires_at = @conversation.active_scenario_expires_at
|
|
current_key = @conversation.active_scenario_key
|
|
scenario_key = name
|
|
|
|
if current_key == scenario_key && expires_at.present? && expires_at >= now
|
|
@conversation.update!(active_scenario_expires_at: 15.minutes.from_now)
|
|
return
|
|
end
|
|
|
|
@conversation.update!(
|
|
active_scenario_key: scenario_key,
|
|
active_scenario_expires_at: 15.minutes.from_now,
|
|
active_scenario_state: initial_sticky_state
|
|
)
|
|
rescue StandardError => e
|
|
Rails.logger.warn "[ScenarioDelegatorTool] Failed to ensure sticky session: #{e.message}"
|
|
end
|
|
|
|
def initial_sticky_state
|
|
contact = @conversation&.contact
|
|
{
|
|
'stage' => 'initial',
|
|
'collected' => {
|
|
'name' => contact&.name.to_s.strip.presence,
|
|
'cpf' => contact&.custom_attributes&.fetch('cpf', nil)
|
|
}.compact,
|
|
'last_tool_results' => {},
|
|
'attempt_count' => 0,
|
|
'started_at' => Time.current.iso8601
|
|
}
|
|
end
|
|
|
|
def build_state_context(pergunta_interna)
|
|
state = @conversation&.active_scenario_state || {}
|
|
contact = @conversation&.contact
|
|
|
|
context_payload = {
|
|
'pergunta_atual' => pergunta_interna.to_s,
|
|
'stage_atual' => state['stage'] || 'initial',
|
|
'dados_confirmados' => state['collected'] || {},
|
|
'contato' => {
|
|
'nome' => contact&.name.to_s.strip.presence,
|
|
'cpf' => contact&.custom_attributes&.fetch('cpf', nil),
|
|
'telefone' => contact&.phone_number
|
|
}.compact
|
|
}
|
|
|
|
JSON.generate(context_payload)
|
|
rescue StandardError
|
|
'{}'
|
|
end
|
|
|
|
def update_sticky_session_state(final_output)
|
|
return unless @conversation.respond_to?(:active_scenario_state)
|
|
|
|
state = @conversation.active_scenario_state || {}
|
|
updated = state.deep_merge(
|
|
'last_output' => final_output.to_s.first(500),
|
|
'updated_at' => Time.current.iso8601
|
|
)
|
|
|
|
@conversation.update!(
|
|
active_scenario_state: updated,
|
|
active_scenario_expires_at: 15.minutes.from_now
|
|
)
|
|
rescue StandardError => e
|
|
Rails.logger.warn "[ScenarioDelegatorTool] Failed to update sticky state: #{e.message}"
|
|
end
|
|
|
|
def completion_signal?(text)
|
|
return false if text.blank?
|
|
|
|
completion_patterns = [
|
|
/pix (gerado|enviado)/i,
|
|
/reserva (confirmada|criada)/i,
|
|
/aguardo o pagamento/i,
|
|
/pagamento/i
|
|
]
|
|
|
|
completion_patterns.any? { |pattern| text.match?(pattern) }
|
|
end
|
|
|
|
def clear_sticky_session
|
|
return unless @conversation.respond_to?(:active_scenario_key)
|
|
|
|
@conversation.update!(
|
|
active_scenario_key: nil,
|
|
active_scenario_expires_at: nil,
|
|
active_scenario_state: {}
|
|
)
|
|
rescue StandardError => e
|
|
Rails.logger.warn "[ScenarioDelegatorTool] Failed to clear sticky session: #{e.message}"
|
|
end
|
|
|
|
def log_scenario_run(final_output)
|
|
payload = {
|
|
service: 'ScenarioDelegator',
|
|
scenario_key: name,
|
|
conversation_id: @conversation&.id,
|
|
account_id: @conversation&.account_id,
|
|
stage: @conversation&.active_scenario_state&.dig('stage'),
|
|
tools: @scenario&.tools,
|
|
output_length: final_output.to_s.length,
|
|
timestamp: Time.current.iso8601
|
|
}
|
|
|
|
Rails.logger.info payload.to_json
|
|
rescue StandardError => e
|
|
Rails.logger.warn "[ScenarioDelegatorTool] Failed to log scenario run: #{e.message}"
|
|
end
|
|
|
|
def attempt_fallback(pergunta_interna)
|
|
return nil unless @conversation
|
|
|
|
name, cpf = extract_name_and_cpf(pergunta_interna)
|
|
update_contact(name: name, cpf: cpf) if name.present? || cpf.present?
|
|
|
|
pending = Captain::Reservation.where(conversation_id: @conversation.id, status: 'pending_payment').last
|
|
charge = current_pix_charge_for(pending) if pending
|
|
|
|
if charge&.pix_copia_e_cola.present?
|
|
if charge.expired? || charge.expired_by_time?
|
|
charge.update!(status: 'expired') unless charge.expired?
|
|
pix_msg = generate_pix
|
|
return pix_msg if pix_msg.present?
|
|
else
|
|
send_pix_message(charge.pix_copia_e_cola)
|
|
return 'Reenviei o Pix Copia e Cola para você concluir o pagamento.'
|
|
end
|
|
end
|
|
|
|
draft = Captain::Reservation.where(conversation_id: @conversation.id, status: 'draft').last
|
|
unless draft
|
|
create_msg = create_reservation_intent
|
|
draft = Captain::Reservation.where(conversation_id: @conversation.id, status: 'draft').last
|
|
return create_msg if draft.blank? && create_msg.present?
|
|
end
|
|
|
|
return 'Para gerar o Pix, preciso do seu CPF.' if cpf.blank?
|
|
|
|
pix_msg = generate_pix
|
|
return pix_msg[:formatted_message] if pix_msg.is_a?(Hash) && pix_msg[:formatted_message].present?
|
|
|
|
pix_msg.is_a?(String) ? pix_msg : nil
|
|
end
|
|
|
|
def extract_name_and_cpf(text)
|
|
return [nil, nil] if text.blank?
|
|
|
|
# 1. Extração de CPF (mantém a lógica atual que funciona)
|
|
cpf_match = text.match(/(\d{3}\.?\d{3}\.?\d{3}-?\d{2})/)
|
|
cpf = cpf_match ? cpf_match[1].gsub(/\D/, '') : nil
|
|
|
|
# 2. Extração de Nome com Bloqueio de Lixo Técnico
|
|
lines = text.to_s.split("\n").map(&:strip).reject(&:blank?)
|
|
|
|
# Filtramos linhas que sabemos ser cabeçalhos injetados
|
|
blacklist_patterns = [
|
|
'Histórico da Conversa',
|
|
'Instrução/Pergunta Atual',
|
|
'Contexto do Contato',
|
|
'Regra:',
|
|
'DADO CONFIRMADO'
|
|
]
|
|
|
|
# Busca a primeira linha que não seja um padrão técnico e não tenha ":" (que indica chave: valor ou cabeçalho)
|
|
candidate_line = lines.find do |line|
|
|
blacklist_patterns.none? { |p| line.include?(p) } && line.exclude?(':')
|
|
end
|
|
|
|
name = candidate_line&.strip
|
|
|
|
# Validação final de sanidade para o nome
|
|
if name.present? && (name.length > 60 || name.match?(/[\[\]{}]/))
|
|
# Um nome real não deve ser absurdamente longo e geralmente não tem caracteres especiais de sistema
|
|
name = nil
|
|
end
|
|
|
|
[name.presence, cpf.presence]
|
|
end
|
|
|
|
def build_contact_context
|
|
return 'Contexto do Contato: sem dados.' unless @conversation&.contact
|
|
|
|
contact = @conversation.contact
|
|
name = contact.name.to_s.strip
|
|
cpf = contact.custom_attributes['cpf'].to_s.strip
|
|
|
|
context_lines = ['Contexto do Contato:']
|
|
context_lines << "nome: #{name.presence || 'desconhecido'}"
|
|
context_lines << "cpf: #{cpf.presence || 'nao informado'}"
|
|
context_lines << 'Regra: se nome e cpf ja existem, nao repetir a pergunta inicial de identificacao.'
|
|
context_lines.join("\n")
|
|
end
|
|
|
|
def update_contact(name:, cpf:)
|
|
tool = Captain::Tools::UpdateContactTool.new(@assistant, user: @user, conversation: @conversation)
|
|
tool.execute(nome: name, cpf: cpf)
|
|
rescue StandardError => e
|
|
Rails.logger.warn "[ScenarioDelegatorTool] Fallback update_contact failed: #{e.message}"
|
|
nil
|
|
end
|
|
|
|
def create_reservation_intent
|
|
tool = Captain::Tools::CreateReservationIntentTool.new(@assistant, user: @user, conversation: @conversation)
|
|
tool.execute({})
|
|
rescue StandardError => e
|
|
Rails.logger.warn "[ScenarioDelegatorTool] Fallback create_reservation_intent failed: #{e.message}"
|
|
nil
|
|
end
|
|
|
|
def generate_pix
|
|
tool = Captain::Tools::GeneratePixTool.new(@assistant, user: @user, conversation: @conversation)
|
|
tool.execute({})
|
|
rescue StandardError => e
|
|
Rails.logger.warn "[ScenarioDelegatorTool] Fallback generate_pix failed: #{e.message}"
|
|
nil
|
|
end
|
|
|
|
def send_pix_message(pix_code)
|
|
return if pix_code.blank?
|
|
|
|
@conversation.messages.create!(
|
|
content: pix_code,
|
|
message_type: :outgoing,
|
|
account: @conversation.account,
|
|
inbox: @conversation.inbox,
|
|
sender: @assistant
|
|
)
|
|
end
|
|
|
|
def current_pix_charge_for(reservation)
|
|
return nil unless reservation
|
|
|
|
return reservation.current_pix_charge if reservation.respond_to?(:current_pix_charge)
|
|
|
|
Captain::PixCharge.where(reservation_id: reservation.id).order(created_at: :desc).first
|
|
end
|
|
end
|