iachat/enterprise/app/services/captain/tools/get_reserva_preco_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

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