472 lines
18 KiB
Ruby
472 lines
18 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, Hidro ou Spa)'
|
|
},
|
|
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)
|
|
account_id = @conversation&.account_id || @assistant&.account_id
|
|
|
|
File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f|
|
|
f.puts "[#{Time.now}] STARTING CheckAvailabilityTool with params: #{actual_params}"
|
|
f.puts "[#{Time.now}] PRICING COUNT: #{Captain::Pricing.where(account_id: account_id).count}"
|
|
f.puts "[#{Time.now}] FIRST PRICING: #{Captain::Pricing.where(account_id: account_id).first.inspect}"
|
|
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}" }
|
|
|
|
# [DEBUG] Log the context
|
|
current_inbox_id = @conversation&.inbox_id
|
|
# [KEYWORD SEARCH]
|
|
# 1. First, find if the term matches any Brand suite_keywords or suite_categories
|
|
account_brands = Captain::Brand.where(account_id: account_id)
|
|
|
|
# Try to find a category that matches the input directly (case insensitive)
|
|
matched_category = nil
|
|
|
|
normalized_input = suite_category.to_s.strip.downcase
|
|
|
|
# Iterate over brands to find a match in categories or keywords
|
|
account_brands.find_each do |brand|
|
|
# Check direct category name match
|
|
found_cat = brand.suite_categories&.find { |cat| cat.to_s.downcase == normalized_input }
|
|
if found_cat
|
|
matched_category = found_cat
|
|
break
|
|
end
|
|
|
|
# Check keywords match
|
|
# suite_keywords is a Hash: { "Category Name" => "keyword1, keyword2" }
|
|
brand.suite_keywords&.each do |cat_name, keywords_str|
|
|
next if keywords_str.blank?
|
|
|
|
keywords_list = keywords_str.to_s.downcase.split(',').map(&:strip)
|
|
if keywords_list.any? { |kw| normalized_input.include?(kw) }
|
|
matched_category = cat_name
|
|
break
|
|
end
|
|
end
|
|
break if matched_category
|
|
end
|
|
|
|
# Use the matched category if found, otherwise stick to the original input (fallback)
|
|
final_suite_category = matched_category || suite_category
|
|
|
|
File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f|
|
|
f.puts "[#{Time.now}] KEYWORD MATCH: Input='#{suite_category}' -> Resolved='#{final_suite_category}'"
|
|
end
|
|
|
|
pricing_scope = Captain::Pricing.where(account_id: account_id)
|
|
.where('suite_category ILIKE ?', final_suite_category)
|
|
|
|
# [INBOX PRIORITY] Filter by Current Inbox > Global
|
|
current_inbox_id = @conversation&.inbox_id
|
|
pricing_scope = if current_inbox_id.present?
|
|
# STRICT MODE: Only fetch prices for THIS specific inbox.
|
|
# Supports both legacy (inbox_id column) and new (has_many through join table)
|
|
pricing_scope.left_joins(:inboxes)
|
|
.where('captain_pricings.inbox_id = :id OR captain_pricing_inboxes.inbox_id = :id', id: current_inbox_id)
|
|
.distinct
|
|
else
|
|
# No Context (Playground/Test): Global Only
|
|
pricing_scope.where(inbox_id: nil)
|
|
end
|
|
|
|
# Sort in Ruby to ensure Specific Inbox (non-nil) comes before Global (nil)
|
|
# This implements the "Override" behavior.
|
|
pricing_scope = pricing_scope.sort_by { |p| p.inbox_id ? 0 : 1 }
|
|
|
|
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 #{final_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 #{final_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 #{final_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(final_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 #{final_suite_category}: #{available_options}. Pergunte qual ele prefere."
|
|
else
|
|
msg = "Não encontrei tarifas cadastradas para a suíte #{final_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
|
|
rescue StandardError => e
|
|
File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f|
|
|
f.puts "[#{Time.now}] CRITICAL ERROR in CheckAvailabilityTool: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
|
|
end
|
|
raise e
|
|
end
|
|
|
|
private
|
|
|
|
# Helper to ensure we have a conversation object
|
|
def ensure_conversation_context!
|
|
return if @conversation.present?
|
|
end
|
|
|
|
def resolve_account_id(conversation, assistant)
|
|
conversation&.account_id || assistant&.account_id
|
|
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|
|
|
p_dur = pricing.duration.to_s.strip.downcase
|
|
p_dur == requested_text || p_dur.include?(requested_text) || requested_text.include?(p_dur) || normalize_duration_input(p_dur) == normalized_request
|
|
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
|