chatwoot-develop/enterprise/app/services/captain/tools/check_availability_tool.rb
2026-01-19 19:26:23 -03:00

401 lines
14 KiB
Ruby

module Captain
module Tools
class CheckAvailabilityTool < BaseTool
def name
'check_availability'
end
def description
'Checks availability and price for a hotel suite. Requires "suite" (e.g., Stilo, Master) and "duration" (default 1). Returns the calculated price.'
end
def tool_parameters_schema
{
type: 'object',
properties: {
suite: {
type: 'string',
description: 'Nome da suíte/categoria (ex: Stilo, Master ou Hidro)'
},
duration: {
type: 'integer',
description: 'Duração em horas (padrão: 1)'
},
date: {
type: 'string',
description: 'Data desejada para a reserva (ex: 20/01/2026 ou 20 de janeiro)'
},
check_in_at: {
type: 'string',
description: 'Data/hora desejada para o check-in (ISO ou texto livre)'
}
},
required: %w[suite]
}
end
def execute(*args, **params)
actual_params = resolve_params(args, params)
File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f|
f.puts "[#{Time.now}] STARTING CheckAvailabilityTool with params: #{actual_params}"
end
suite_category = actual_params[:suite]
requested_duration = actual_params[:duration].presence # Don't default yet
if suite_category.blank?
return "Por favor, pergunte ao cliente: 'Qual suíte você prefere (Stilo, Alexa ou Hidro) e por quanto tempo gostaria de ficar?'."
end
ensure_conversation_context!
# ... (Anti-Hallucination logic remains same) ...
# [DATE RESOLUTION]
target_date = resolve_target_date(actual_params)
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] RESOLVED DATE: #{target_date} | SUITE: #{suite_category}" }
# Find pricing strategy
pricing_scope = Captain::Pricing.where(account_id: @conversation.account_id)
.where('LOWER(suite_category) = ?', suite_category.downcase)
pricing_scope = filter_pricings_by_day_range(pricing_scope, target_date) if target_date
# [AUTO-MENU MODE] If duration is missing, return all options
if requested_duration.blank?
available_options = pricing_scope.map do |p|
"#{p.duration}: #{ActiveSupport::NumberHelper.number_to_currency(p.price.to_f, unit: 'R$ ', separator: ',', delimiter: '.')}"
end.join(', ')
if available_options.present?
msg = "Disponível! Para a suíte #{suite_category} em #{target_date&.strftime('%d/%m')}, tenho estas opções: #{available_options}. Pergunte qual duração o cliente prefere."
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] MENU MODE: #{msg}" }
return msg
else
msg = "Não encontrei tarifas para a suíte #{suite_category} nesta data. Confirme o nome da suíte."
return msg
end
end
pricing = pick_pricing_for_duration(pricing_scope, requested_duration)
if pricing
final_price = pricing.price.to_f
msg = "Disponível! A Suíte #{suite_category} para #{requested_duration}h em #{target_date&.strftime('%d/%m')} está saindo por #{ActiveSupport::NumberHelper.number_to_currency(
final_price, unit: 'R$ ', separator: ',', delimiter: '.'
)} (#{pricing.day_range})."
persist_last_availability(suite_category, requested_duration, pricing, target_date)
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] SUCCESS: #{msg}" }
return msg
else
available_options = pricing_scope.map do |p|
"#{p.duration}: #{ActiveSupport::NumberHelper.number_to_currency(p.price.to_f, unit: 'R$ ', separator: ',', delimiter: '.')}"
end.join(', ')
if available_options.present?
msg = "Não encontrei tarifa exata para #{requested_duration}h. IMPORTANTE: Informe ao cliente que temos estas opções disponíveis para #{suite_category}: #{available_options}. Pergunte qual ele prefere."
else
msg = "Não encontrei tarifas cadastradas para a suíte #{suite_category} nesta data (#{target_date}). Por favor, confirme se o nome da suíte está correto."
end
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] FAILURE: #{msg}" }
return msg
end
end
private
# Helper to ensure we have a conversation object
def ensure_conversation_context!
return if @conversation.present?
end
def infer_unit
@conversation&.inbox&.captain_inbox&.unit
end
def persist_last_availability(suite_category, duration, pricing, target_date)
return unless @conversation
@conversation.custom_attributes ||= {}
price_value = pricing&.price&.to_f
@conversation.custom_attributes['last_availability'] = {
suite: suite_category,
duration: duration,
price: price_value,
day_range: pricing.day_range,
date: target_date&.iso8601,
captured_at: Time.zone.now.iso8601
}
@conversation.save!
update_sticky_state(suite_category, duration, pricing, target_date)
rescue StandardError => e
Rails.logger.warn "[CheckAvailabilityTool] Failed to persist last availability: #{e.message}"
end
def update_sticky_state(suite_category, duration, pricing, target_date)
return unless @conversation.respond_to?(:active_scenario_state)
state = @conversation.active_scenario_state || {}
price_value = pricing&.price&.to_f
collected = (state['collected'] || {}).merge(
'suite' => suite_category,
'duration' => duration,
'date' => target_date&.iso8601
).compact
last_tool_results = (state['last_tool_results'] || {}).merge(
'check_availability' => {
'suite' => suite_category,
'duration' => duration,
'price' => price_value,
'day_range' => pricing.day_range,
'date' => target_date&.iso8601,
'captured_at' => Time.zone.now.iso8601
}
)
@conversation.update!(
active_scenario_state: state.merge(
'stage' => 'availability_checked',
'collected' => collected,
'last_tool_results' => last_tool_results,
'updated_at' => Time.current.iso8601
)
)
rescue StandardError => e
Rails.logger.warn "[CheckAvailabilityTool] Failed to update sticky state: #{e.message}"
end
def pick_pricing_for_duration(scope, requested_duration)
pricings = scope.to_a
return pricings.first if requested_duration.blank? # Se não pediu duração, qualquer uma serve
return nil if pricings.empty?
# Normaliza a entrada do usuário (ex: "três horas" -> 3, "3h" -> 3)
normalized_request = normalize_duration_input(requested_duration)
# 1. Tenta match exato pelo número normalizado
if normalized_request.is_a?(Integer)
matched = pricings.find do |pricing|
extract_duration_number(pricing.duration) == normalized_request
end
return matched if matched
end
# 2. Tenta match pelo texto normalizado (ex: "pernoite")
requested_text = requested_duration.to_s.strip.downcase
matched = pricings.find do |pricing|
pricing.duration.to_s.strip.downcase == requested_text
end
return matched if matched
# [FIX] Strict Mode com Log:
Rails.logger.warn "[CheckAvailabilityTool] Nenhuma tarifa encontrada para '#{requested_duration}' (Normalizado: #{normalized_request}). Opcoes: #{pricings.map(&:duration)}"
nil
end
def normalize_duration_input(input)
text = input.to_s.downcase.strip
# Mapa de extenso para números
word_to_num = {
'um' => 1, 'uma' => 1,
'dois' => 2, 'duas' => 2,
'tres' => 3, 'três' => 3,
'quatro' => 4,
'cinco' => 5,
'seis' => 6,
'doze' => 12
}
# Verifica palavras por extenso
word_to_num.each do |word, num|
return num if text.include?(word)
end
# Verifica dígitos
match = text.match(/(\d+)/)
return match[1].to_i if match
text # Retorna o texto original se não for número (ex: "pernoite")
end
def extract_duration_number(value)
return nil if value.blank?
text = value.to_s.downcase
match = text.match(/(\d+)/)
match ? match[1].to_i : nil
end
def resolve_target_date(actual_params)
date_text = actual_params[:date].presence || actual_params[:data].presence
check_in_at = actual_params[:check_in_at].presence
# 1. Try to get date from param or history FIRST
base_date = parse_date_from_text(date_text) if date_text.present?
base_date ||= infer_date_from_history
# 2. If we have a check_in_at time
if check_in_at.present?
parsed_time = begin
Time.zone.parse(check_in_at.to_s)
rescue StandardError
nil
end
if parsed_time
# If check_in_at is just a time (e.g. "21:00"), combine it with base_date
return base_date if base_date && check_in_at.to_s.length <= 5 # Likely just HH:MM
return parsed_time.to_date
end
end
base_date || Time.zone.today
end
def infer_date_from_history
return nil unless @conversation
messages = @conversation.messages.incoming.order(created_at: :desc).limit(12).to_a
# [CRITICAL RESET FIX] If there is a reset in history, stop looking further back
reset_msg = messages.find { |m| m.content.to_s.downcase.match?(/\b(reiniciar|resetar|comecar de novo)\b/i) }
if reset_msg
# Keep only messages after reset
messages = messages.take_while { |m| m.id != reset_msg.id }
end
messages.each do |message|
text = message.content.to_s
next if text.blank?
date = parse_date_from_text(text)
return date if date.present?
end
nil
end
def filter_pricings_by_day_range(scope, target_date)
return scope if target_date.blank?
target_wday = target_date.wday
pricings = scope.to_a
matched = pricings.select do |pricing|
day_range_matches_wday?(pricing.day_range, target_wday)
end
matched.any? ? matched : scope
end
def day_range_matches_wday?(day_range, wday)
return false if day_range.blank?
days = normalize_day_range(day_range)
days.include?(wday)
end
def normalize_day_range(day_range)
normalized = ActiveSupport::Inflector.transliterate(day_range.to_s).upcase
normalized = normalized.gsub(/\s+/, ' ').strip
mapping = {
'SEGUNDA' => 1,
'TERCA' => 2,
'QUARTA' => 3,
'QUINTA' => 4,
'SEXTA' => 5,
'SABADO' => 6,
'DOMINGO' => 0
}
if normalized.include?(' A ')
start_name, end_name = normalized.split(' A ').map(&:strip)
start_idx = mapping[start_name]
end_idx = mapping[end_name]
return [] if start_idx.nil? || end_idx.nil?
return (start_idx..end_idx).to_a if start_idx <= end_idx
return (start_idx..6).to_a + (0..end_idx).to_a
end
normalized.split(',').map(&:strip).map { |name| mapping[name] }.compact
end
def parse_date_from_text(text)
normalized = text.to_s.downcase
ascii = ActiveSupport::Inflector.transliterate(normalized)
return Time.zone.today + 2.days if normalized.include?('depois de amanha')
return Time.zone.today + 1.day if normalized.include?('amanha')
return Time.zone.today if normalized.include?('hoje')
if (match = normalized.match(%r{\b(\d{1,2})/(\d{1,2})(?:/(\d{2,4}))?\b}))
day = match[1].to_i
month = match[2].to_i
year = match[3].to_i
year += 2000 if year.positive? && year < 100
year = Time.zone.today.year if year.zero?
return Date.new(year, month, day)
end
months = {
'jan' => 1, 'janeiro' => 1,
'fev' => 2, 'fevereiro' => 2,
'mar' => 3, 'marco' => 3,
'abr' => 4, 'abril' => 4,
'mai' => 5, 'maio' => 5,
'jun' => 6, 'junho' => 6,
'jul' => 7, 'julho' => 7,
'ago' => 8, 'agosto' => 8,
'set' => 9, 'setembro' => 9,
'out' => 10, 'outubro' => 10,
'nov' => 11, 'novembro' => 11,
'dez' => 12, 'dezembro' => 12
}
month_pattern = months.keys.join('|')
if (match = ascii.match(/\b(?:dia\s*)?(\d{1,2})\s*(?:de\s*)?(#{month_pattern})(?:\s*(?:de\s*)?(\d{2,4}))?\b/))
day = match[1].to_i
month = months[match[2]]
year = match[3].to_i
year += 2000 if year.positive? && year < 100
year = Time.zone.today.year if year.zero?
date = Date.new(year, month, day)
date = date.next_year if match[3].blank? && date < Time.zone.today
return date
end
weekdays = {
'segunda' => 1,
'terca' => 2,
'terça' => 2,
'quarta' => 3,
'quinta' => 4,
'sexta' => 5,
'sabado' => 6,
'sábado' => 6,
'domingo' => 0
}
weekdays.each do |name, wday|
next unless normalized.include?(name)
today = Time.zone.today
days_ahead = (wday - today.wday) % 7
days_ahead = 7 if days_ahead.zero?
date = today + days_ahead.days
date += 7.days if normalized.include?('que vem')
return date
end
nil
end
end
end
end