iachat/enterprise/app/services/captain/tools/generate_roleta_link_tool.rb
Rodribm10 cfffea9c16 feat(captain): semantic memory fixes + roleta + reclamações + analytics
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>
2026-04-21 15:36:25 -03:00

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 se precisar
forçar a oferta fora do fluxo normal (reserva 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