feat(captain/mcp): tool check_suite_availability — bridge PlugPlay
Tool nova reusa as Captain::CustomTool 'custom_status_suites_<unidade>' já cadastradas no painel (Ferramentas) que apontam pro PlugPlay (oxpi.com.br/PlugPlay/SuitesStatus) com auth headers PLUG-PLAY-ID + PLUG-PLAY-TOKEN específicos por unit. Hermes consulta antes de generate_pix pra confirmar disponibilidade. Resolve unit via Assistant.captain_unit_id; mapping unit → CustomTool é por substring no nome (qnn/dolce/express/primeal/primevl). Filtro opcional por suite_category. Retorno textual agrupado por classe com contagem livre/ocupada e número das suítes livres (max 8). Validado em Qnn01: 4 hidromassagens livres (101,102,103,108) / 15 Master livres / 5 Standard livres — bate com painel PlugPlay. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c8785b999c
commit
42ada8100e
@ -18,6 +18,7 @@ class Captain::Mcp::ToolRegistry
|
|||||||
Captain::Mcp::Tools::SendSuiteImagesTool,
|
Captain::Mcp::Tools::SendSuiteImagesTool,
|
||||||
Captain::Mcp::Tools::RescheduleReservationTool,
|
Captain::Mcp::Tools::RescheduleReservationTool,
|
||||||
Captain::Mcp::Tools::ReactToMessageTool,
|
Captain::Mcp::Tools::ReactToMessageTool,
|
||||||
|
Captain::Mcp::Tools::CheckSuiteAvailabilityTool,
|
||||||
# Construtor (admin scope) — usadas pelo profile Hermes "construtor" pra criar novos agentes
|
# Construtor (admin scope) — usadas pelo profile Hermes "construtor" pra criar novos agentes
|
||||||
Captain::Mcp::Tools::ListAssistantsTool,
|
Captain::Mcp::Tools::ListAssistantsTool,
|
||||||
Captain::Mcp::Tools::GetAssistantPricingTool,
|
Captain::Mcp::Tools::GetAssistantPricingTool,
|
||||||
|
|||||||
@ -0,0 +1,159 @@
|
|||||||
|
# Tool MCP: consulta status real-time das suítes da unidade.
|
||||||
|
#
|
||||||
|
# Reusa as Captain::CustomTool já cadastradas no painel (Ferramentas →
|
||||||
|
# status_suites_<unidade>) que já apontam pro PlugPlay (oxpi.com.br) com
|
||||||
|
# auth headers PLUG-PLAY-ID + PLUG-PLAY-TOKEN específicos por unit.
|
||||||
|
#
|
||||||
|
# Caso de uso: cliente pediu pra reservar hidromassagem; antes de gerar
|
||||||
|
# Pix, agente confere se tem suíte livre ("Quer hidro pra hoje 23h? Tenho
|
||||||
|
# 2 livres no momento" vs "Hidro tá lotada hoje, posso te oferecer Luxo?").
|
||||||
|
#
|
||||||
|
# Resolve unit via Assistant.captain_unit_id (preferencial) ou CaptainInbox
|
||||||
|
# (fallback). Mapping unit → CustomTool é por substring no nome da unit.
|
||||||
|
class Captain::Mcp::Tools::CheckSuiteAvailabilityTool < Captain::Mcp::Tools::BaseTool
|
||||||
|
# Match unit name → sufixo do slug da Captain::CustomTool. Mantém em código
|
||||||
|
# porque são 8 unidades fixas; se virar dezenas, vira coluna em Captain::Unit.
|
||||||
|
UNIT_NAME_PATTERNS = {
|
||||||
|
/qnn|midhaus/i => 'qnn01',
|
||||||
|
/dolce/i => 'dolceamore',
|
||||||
|
/express/i => 'primeexpress',
|
||||||
|
/prime\s*al|águas\s*lindas/i => 'primeal',
|
||||||
|
/prime\s*vl|prime\s*ade|cei|asa\s*norte/i => 'primevl'
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
TIMEOUT_SECONDS = 8
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def name
|
||||||
|
'check_suite_availability'
|
||||||
|
end
|
||||||
|
|
||||||
|
def description
|
||||||
|
'Consulta em tempo real quais suítes estão livres na unidade. Use ANTES de chamar generate_pix ' \
|
||||||
|
'quando o cliente quiser fechar — confirma se a categoria desejada tem disponibilidade. ' \
|
||||||
|
'Retorna lista por categoria com contagem livre/ocupada e número da suíte.'
|
||||||
|
end
|
||||||
|
|
||||||
|
def input_schema
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
conversation_id: {
|
||||||
|
type: 'integer',
|
||||||
|
description: 'ID da conversa (cid do [ctx]). Obrigatório pra resolver a unidade.'
|
||||||
|
},
|
||||||
|
suite_category: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filtra por categoria (ex: "hidromassagem", "luxo", "standard"). Opcional — sem isso traz todas.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['conversation_id']
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def call(args, context:)
|
||||||
|
conversation = resolve_conversation(args, context)
|
||||||
|
return error_response('Conversa não encontrada. Passe conversation_id (cid do [ctx]).') if conversation.blank?
|
||||||
|
|
||||||
|
unit = resolve_unit(conversation, context)
|
||||||
|
return error_response('Unidade do Captain não vinculada à conversa.') if unit.blank?
|
||||||
|
|
||||||
|
tool = find_status_tool(unit)
|
||||||
|
return error_response("Unidade '#{unit.name}' não tem custom_status_suites cadastrado. Avise a gerência.") if tool.nil?
|
||||||
|
|
||||||
|
suites = fetch_suites(tool)
|
||||||
|
return error_response('Falha ao consultar status das suítes (timeout/auth). Cliente que aguarde.') if suites.nil?
|
||||||
|
|
||||||
|
summary = summarize(suites, args['suite_category'])
|
||||||
|
text_response(summary)
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("[Captain::Mcp::CheckSuiteAvailabilityTool] error: #{e.class}: #{e.message}")
|
||||||
|
error_response("Erro: #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def resolve_conversation(args, context)
|
||||||
|
conv_id = args['conversation_id'].presence ||
|
||||||
|
context[:conversation_internal_id] ||
|
||||||
|
context[:conversation_id]
|
||||||
|
return nil if conv_id.blank?
|
||||||
|
|
||||||
|
Conversation.find_by(id: conv_id) || Conversation.find_by(display_id: conv_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve_unit(conversation, context)
|
||||||
|
asst_id = context && (context[:assistant_id] || context['assistant_id'])
|
||||||
|
if asst_id
|
||||||
|
asst = Captain::Assistant.find_by(id: asst_id)
|
||||||
|
return asst.captain_unit if asst&.captain_unit_id.present?
|
||||||
|
end
|
||||||
|
ci = CaptainInbox.find_by(inbox_id: conversation.inbox_id)
|
||||||
|
ci&.captain_unit
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_status_tool(unit)
|
||||||
|
suffix = UNIT_NAME_PATTERNS.find { |re, _| re.match?(unit.name.to_s) }&.last
|
||||||
|
return nil if suffix.blank?
|
||||||
|
|
||||||
|
Captain::CustomTool.find_by(account_id: unit.account_id,
|
||||||
|
slug: "custom_status_suites_#{suffix}",
|
||||||
|
enabled: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_suites(tool)
|
||||||
|
headers = headers_for(tool)
|
||||||
|
response = HTTParty.get(tool.endpoint_url, headers: headers, timeout: TIMEOUT_SECONDS)
|
||||||
|
return nil unless response.success?
|
||||||
|
|
||||||
|
parsed = JSON.parse(response.body)
|
||||||
|
return nil unless parsed.is_a?(Array)
|
||||||
|
|
||||||
|
parsed
|
||||||
|
rescue HTTParty::Error, Net::ReadTimeout, Net::OpenTimeout, JSON::ParserError => e
|
||||||
|
Rails.logger.warn("[Captain::Mcp::CheckSuiteAvailabilityTool] fetch falhou: #{e.class}: #{e.message}")
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def headers_for(tool)
|
||||||
|
return {} unless tool.auth_type == 'custom_headers'
|
||||||
|
|
||||||
|
list = tool.auth_config.to_h['headers'].to_a
|
||||||
|
list.each_with_object({}) { |h, acc| acc[h['name'].to_s] = h['value'].to_s }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Agrupa por classe e mostra livre/ocupada + nº das suítes livres. Filtra
|
||||||
|
# por categoria se passado. Retorno é texto curto pro LLM repassar resumido.
|
||||||
|
def summarize(suites, filter_category)
|
||||||
|
grouped = group_filtered(suites, filter_category)
|
||||||
|
return "Nenhuma suíte da categoria '#{filter_category}' nesta unidade." if filter_category.present? && grouped.empty?
|
||||||
|
|
||||||
|
lines = grouped.map { |classe, list| summary_line(classe, list) }
|
||||||
|
header = filter_category.present? ? "Status #{filter_category} agora:" : 'Status das suítes agora:'
|
||||||
|
[header, *lines].join("\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
def group_filtered(suites, filter_category)
|
||||||
|
grouped = suites.group_by { |s| s['classe'].to_s.upcase.strip }
|
||||||
|
return grouped if filter_category.blank?
|
||||||
|
|
||||||
|
grouped.select { |k, _| k.include?(filter_category.to_s.upcase) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def summary_line(classe, list)
|
||||||
|
livres = list.select { |s| status_livre?(s) }
|
||||||
|
ocupadas = list.size - livres.size
|
||||||
|
refs = livres.map { |s| s['ref'].to_s }.reject(&:blank?).first(8).join(', ')
|
||||||
|
suffix = livres.any? ? " — #{refs}" : ''
|
||||||
|
"- #{classe.titleize}: #{livres.size} livre(s) / #{ocupadas} ocupada(s)#{suffix}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def status_livre?(suite)
|
||||||
|
return false if suite['isOcupado']
|
||||||
|
return true if suite['statusId'] == 1
|
||||||
|
return true if suite['status'].to_s.casecmp('Livre').zero?
|
||||||
|
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in New Issue
Block a user