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>
292 lines
8.8 KiB
Ruby
292 lines
8.8 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# rubocop:disable Metrics/ClassLength,Metrics/AbcSize
|
|
# Consulta preco direto no Supabase (schema reserva_hotel) a partir da
|
|
# marca/unidade vinculada ao inbox da conversa atual. Substitui o uso de
|
|
# faq_lookup para perguntas de preco - garante valor correto e atualizado.
|
|
class Captain::Tools::GetReservaPrecoTool < Captain::Tools::BaseTool
|
|
DEFAULT_SCHEMA = 'reserva_hotel'
|
|
|
|
def name
|
|
'get_reserva_preco'
|
|
end
|
|
|
|
def description
|
|
<<~DESC.strip
|
|
Consulta o preco de uma suite direto no banco de reservas (fonte oficial).
|
|
USE SEMPRE que o cliente perguntar valor/preco de categoria + permanencia.
|
|
NAO use faq_lookup para preco - use esta tool. Retorna o valor exato,
|
|
ja considerando o periodo da semana (segunda-quinta, sexta, sabado, etc).
|
|
A marca e resolvida automaticamente pelo inbox da conversa.
|
|
DESC
|
|
end
|
|
|
|
def tool_parameters_schema
|
|
{
|
|
type: 'object',
|
|
required: %w[categoria permanencia],
|
|
properties: {
|
|
categoria: {
|
|
type: 'string',
|
|
description: 'OBRIGATORIO. Nome da categoria/suite. Ex: "Alexa", "Stilo", "Hidromassagem".'
|
|
},
|
|
permanencia: {
|
|
type: 'string',
|
|
description: 'OBRIGATORIO. Permanencia. Ex: "2hrs", "3hrs", "4hrs", "Pernoite", "Diaria".'
|
|
},
|
|
checkin_at: {
|
|
type: 'string',
|
|
description: 'OPCIONAL. Data/hora de check-in em ISO 8601. Usado para resolver periodo da semana. ' \
|
|
'Se vazio, usa periodo "default".'
|
|
}
|
|
}
|
|
}
|
|
end
|
|
|
|
def execute(*args, **params)
|
|
actual_params = resolve_params(args, params)
|
|
@conversation ||= resolve_conversation(args, params)
|
|
|
|
categoria = actual_params[:categoria].to_s.strip
|
|
permanencia = actual_params[:permanencia].to_s.strip
|
|
checkin_at = actual_params[:checkin_at].to_s.strip
|
|
|
|
return missing_fields_response(categoria, permanencia) if categoria.empty? || permanencia.empty?
|
|
|
|
unit = infer_unit
|
|
return error_response('Inbox desta conversa nao esta vinculado a uma unidade. Nao consigo buscar preco.') if unit.blank?
|
|
|
|
unit_row = fetch_unidade(unit.id)
|
|
return error_response("Unidade #{unit.id} nao encontrada no banco de reservas (reserva_hotel.unidades).") if unit_row.blank?
|
|
|
|
tenant_id = unit_row['tenant_id']
|
|
marca_id = unit_row['id_marca']
|
|
|
|
periodo_slug = resolve_periodo_slug(marca_id, checkin_at)
|
|
preco_row = fetch_preco(tenant_id, marca_id, categoria, permanencia, periodo_slug)
|
|
|
|
return preco_not_found_response(categoria, permanencia, periodo_slug) if preco_row.blank?
|
|
|
|
success_response(preco_row, categoria, permanencia)
|
|
rescue StandardError => e
|
|
Rails.logger.error("[GetReservaPrecoTool] falha: #{e.class} - #{e.message}")
|
|
error_response('Nao consegui consultar o preco agora. Tente novamente em instantes.')
|
|
end
|
|
|
|
private
|
|
|
|
def infer_unit
|
|
@conversation&.inbox&.captain_inbox&.unit
|
|
end
|
|
|
|
def fetch_unidade(chatwoot_unit_id)
|
|
data = supabase_get(
|
|
'unidades',
|
|
{
|
|
'chatwoot_unit_id' => "eq.#{chatwoot_unit_id}",
|
|
'select' => 'id,id_marca,tenant_id,nome'
|
|
}
|
|
)
|
|
Array(data).first
|
|
end
|
|
|
|
def resolve_periodo_slug(marca_id, checkin_at)
|
|
return 'default' if checkin_at.to_s.strip.empty?
|
|
|
|
day_of_week = Time.zone.parse(checkin_at).wday # 0=dom..6=sab
|
|
periodos = supabase_get(
|
|
'marca_periodos',
|
|
{
|
|
'id_marca' => "eq.#{marca_id}",
|
|
'ativo' => 'eq.true',
|
|
'select' => 'slug,dias,ordem',
|
|
'order' => 'ordem.asc'
|
|
}
|
|
)
|
|
|
|
matched = Array(periodos).find do |p|
|
|
dias = p['dias']
|
|
dias.is_a?(Array) && dias.include?(day_of_week)
|
|
end
|
|
|
|
matched&.dig('slug') || 'default'
|
|
rescue ArgumentError, TypeError
|
|
'default'
|
|
end
|
|
|
|
def fetch_preco(tenant_id, marca_id, categoria, permanencia, periodo_slug)
|
|
data = supabase_get(
|
|
'precos',
|
|
{
|
|
'tenant_id' => "eq.#{tenant_id}",
|
|
'id_marca' => "eq.#{marca_id}",
|
|
'categoria' => "eq.#{categoria}",
|
|
'permanencia' => "eq.#{permanencia}",
|
|
'periodo_semana' => "eq.#{periodo_slug}",
|
|
'ativo' => 'eq.true',
|
|
'select' => 'valor,categoria,permanencia,periodo_semana',
|
|
'limit' => '1'
|
|
}
|
|
)
|
|
Array(data).first
|
|
end
|
|
|
|
def supabase_get(table, query)
|
|
url = "#{supabase_url}/rest/v1/#{table}"
|
|
response = supabase_client.get(url, query) do |req|
|
|
req.headers['apikey'] = supabase_key
|
|
req.headers['Authorization'] = "Bearer #{supabase_key}"
|
|
req.headers['Accept-Profile'] = supabase_schema
|
|
req.headers['Accept'] = 'application/json'
|
|
req.headers['Accept-Encoding'] = 'identity'
|
|
end
|
|
|
|
return [] unless response.success?
|
|
|
|
JSON.parse(response.body)
|
|
rescue JSON::ParserError => e
|
|
Rails.logger.warn("[GetReservaPrecoTool] JSON parse error: #{e.message}")
|
|
[]
|
|
end
|
|
|
|
def supabase_client
|
|
@supabase_client ||= Faraday.new do |f|
|
|
f.request :url_encoded
|
|
f.adapter Faraday.default_adapter
|
|
f.options.timeout = 8
|
|
f.options.open_timeout = 4
|
|
end
|
|
end
|
|
|
|
def supabase_url
|
|
ENV.fetch('RESERVA_1001_SUPABASE_URL').chomp('/')
|
|
end
|
|
|
|
def supabase_key
|
|
ENV.fetch('RESERVA_1001_SUPABASE_ANON_KEY')
|
|
end
|
|
|
|
def supabase_schema
|
|
ENV.fetch('RESERVA_1001_SUPABASE_SCHEMA', DEFAULT_SCHEMA)
|
|
end
|
|
|
|
def success_response(preco_row, categoria, permanencia)
|
|
valor = preco_row['valor'].to_f
|
|
formatted = ActiveSupport::NumberHelper.number_to_currency(
|
|
valor, unit: 'R$ ', separator: ',', delimiter: '.'
|
|
)
|
|
periodo = preco_row['periodo_semana']
|
|
|
|
{
|
|
formatted_message: "#{categoria} (#{permanencia}) - #{formatted} [periodo: #{periodo}]. " \
|
|
'Valor oficial do banco de reservas.',
|
|
success: true,
|
|
valor: valor,
|
|
valor_formatado: formatted,
|
|
categoria: categoria,
|
|
permanencia: permanencia,
|
|
periodo_semana: periodo
|
|
}
|
|
end
|
|
|
|
def preco_not_found_response(categoria, permanencia, periodo_slug)
|
|
{
|
|
formatted_message: "Nao encontrei preco cadastrado para #{categoria} / #{permanencia} " \
|
|
"(periodo: #{periodo_slug}). Consulte o admin para cadastrar esse valor.",
|
|
success: false
|
|
}
|
|
end
|
|
|
|
def missing_fields_response(categoria, permanencia)
|
|
missing = []
|
|
missing << 'categoria' if categoria.empty?
|
|
missing << 'permanencia' if permanencia.empty?
|
|
{
|
|
formatted_message: "Preciso de: #{missing.join(', ')}. Pergunte ao cliente e chame a tool de novo.",
|
|
success: false,
|
|
missing_fields: missing
|
|
}
|
|
end
|
|
|
|
def error_response(message)
|
|
{ formatted_message: message, success: false }
|
|
end
|
|
|
|
def resolve_params(args, params)
|
|
merged = params.to_h
|
|
args.each do |arg|
|
|
next unless arg.is_a?(Hash)
|
|
next if tool_context_hash?(arg)
|
|
|
|
merged = arg.merge(merged)
|
|
end
|
|
merged.with_indifferent_access
|
|
end
|
|
|
|
# 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
|
|
|
|
def tool_context_hash?(hash)
|
|
hash.key?(:state) || hash.key?('state') ||
|
|
hash.key?(:context) || hash.key?('context') ||
|
|
hash.key?(:conversation) || hash.key?('conversation')
|
|
end
|
|
end
|
|
# rubocop:enable Metrics/ClassLength,Metrics/AbcSize
|