Consolida o trabalho desta branch de abril/2026 em um bloco pronto pra testar em staging antes do merge pra main. ## Correções de memória semântica - ExtractionService: Princípio Zero + Regra de Ouro (ação consumada vs intenção). - Cenário Daniela_Reservas: Passo 0 de classificação (consulta/intenção/fora). ## Roleta da Sorte (end-to-end) - Schema Supabase + 7 RPCs atômicas (server-side, idempotentes). - Services: Offer, Redeem, WeeklyReport. - Jobs: OfferRouletteJob (hook em ConfirmationService após Pix pago), NotifyRevealed + Scheduler de fallback. - Tool manual GenerateRoletaLinkTool + endpoint público /roleta/notify. - Dashboard /captain/roleta com Resgate + Relatório + anomaly detection. ## Cenário Reclamacoes_Ouvidoria - Triagem P1-P4, framework LAST, Three-level listening, Self-check. - Sem compensação material, detecção de cliente frustrado eleva prioridade. ## Analytics - Funil de conversão /captain/funnel: 5 etapas via regex, zero LLM. - Detector de churn via ChurnOutreach* (cron dias úteis 10h-17h BRT). ## Trabalho pré-existente incluído - Captain Executive Reports (ceo_digest, mattermost_delivery). - get_reserva_preco_tool, Lifecycle ajustes, Reservations UI polimentos. ## Outros - .gitignore: patterns pra credenciais. - Migrations de scenarios idempotentes. - i18n completa pt_BR+en pra roleta/funnel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
106 lines
3.6 KiB
Ruby
106 lines
3.6 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# Tool manual pra oferecer a Roleta da Sorte. O caminho AUTOMÁTICO de disparo
|
|
# (após confirmação do Pix) passa por Captain::Payments::OfferRouletteJob.
|
|
# Esse tool fica disponível pra casos em que alguém queira forçar a oferta
|
|
# manualmente (ex: reserva paga antes do cron detectar, ação de gerência, etc).
|
|
class Captain::Tools::GenerateRoletaLinkTool < Captain::Tools::BaseTool
|
|
def name
|
|
'generate_roleta_link'
|
|
end
|
|
|
|
def description
|
|
<<~DESC.strip
|
|
Oferta manual da Roleta da Sorte pra reserva atual. Normalmente a roleta é enviada
|
|
AUTOMATICAMENTE quando o pagamento do Pix é confirmado. Use este tool só se precisar
|
|
forçar a oferta fora do fluxo normal (reserva já paga que nunca recebeu link).
|
|
Idempotente: chamar 2x na mesma reserva retorna o mesmo link.
|
|
DESC
|
|
end
|
|
|
|
def tool_parameters_schema
|
|
{ type: 'object', properties: {} }
|
|
end
|
|
|
|
def execute(*args, **params)
|
|
conversation = resolve_conversation(args, params)
|
|
return error_response('Não consegui identificar a conversa.') if conversation.blank?
|
|
|
|
reservation = Captain::Reservation.where(conversation_id: conversation.id).order(created_at: :desc).first
|
|
return error_response('Sem reserva vinculada a essa conversa.') if reservation.blank?
|
|
|
|
result = Captain::Roleta::OfferService.new(reservation: reservation).perform
|
|
return error_response(result[:error] || 'Falha ao gerar roleta.') unless result[:success]
|
|
|
|
{ formatted_message: result[:url], raw_payload: result[:url], success: true, was_created: result[:was_created] }
|
|
rescue StandardError => e
|
|
Rails.logger.error("[GenerateRoletaLinkTool] falha: #{e.class} - #{e.message}")
|
|
error_response('Não consegui gerar o link da roleta agora.')
|
|
end
|
|
|
|
private
|
|
|
|
def error_response(msg)
|
|
{ formatted_message: msg, success: false }
|
|
end
|
|
|
|
# Resolvers de conversa — copiado de GenerateReservationLinkTool
|
|
def resolve_conversation(args, params)
|
|
state = extract_state(args, params)
|
|
return nil if state.blank?
|
|
|
|
conversation_state = state_from_context_hash(state, :conversation) || {}
|
|
conversation_id = state_from_context_hash(conversation_state, :id)
|
|
display_id = state_from_context_hash(conversation_state, :display_id)
|
|
account_id = state[:account_id] || state['account_id']
|
|
|
|
conversation = Conversation.find_by(id: conversation_id) if conversation_id.present?
|
|
return conversation if conversation.present?
|
|
return nil if display_id.blank?
|
|
|
|
scope = Conversation.where(display_id: display_id)
|
|
scope = scope.where(account_id: account_id) if account_id.present?
|
|
scope.first
|
|
end
|
|
|
|
def extract_state(args, params)
|
|
context_sources = [
|
|
*args,
|
|
params[:tool_context], params['tool_context'],
|
|
params[:context_wrapper], params['context_wrapper'],
|
|
params[:context], params['context']
|
|
].compact
|
|
|
|
context_sources.each do |source|
|
|
state = extract_state_from_source(source)
|
|
return state if state.present?
|
|
end
|
|
{}
|
|
end
|
|
|
|
def extract_state_from_source(source)
|
|
return source.state if source.respond_to?(:state)
|
|
return state_from_source_context(source) if source.respond_to?(:context)
|
|
return state_from_hash_source(source) if source.is_a?(Hash)
|
|
|
|
nil
|
|
end
|
|
|
|
def state_from_source_context(source)
|
|
context = source.context
|
|
return nil unless context.is_a?(Hash)
|
|
|
|
state_from_context_hash(context, :state)
|
|
end
|
|
|
|
def state_from_hash_source(source)
|
|
state_from_context_hash(source, :state) ||
|
|
source.dig(:context, :state) ||
|
|
source.dig('context', 'state')
|
|
end
|
|
|
|
def state_from_context_hash(hash, key)
|
|
hash[key] || hash[key.to_s]
|
|
end
|
|
end
|