229 lines
8.7 KiB
Ruby
229 lines
8.7 KiB
Ruby
require 'net/http'
|
|
require 'uri'
|
|
|
|
module Captain
|
|
module Tools
|
|
class ToolRunner
|
|
def self.run(assistant:, tool_key:, inbox:, conversation:, additional_data: {})
|
|
new(assistant, tool_key, inbox, conversation, additional_data).run
|
|
end
|
|
|
|
def initialize(assistant, tool_key, inbox, conversation, additional_data)
|
|
@assistant = assistant
|
|
@tool_key = tool_key
|
|
@inbox = inbox
|
|
@conversation = conversation
|
|
@definition = Captain::Tools::Definitions::ALL[tool_key]
|
|
@config = Captain::ToolConfig.find_by(captain_assistant_id: assistant.id, tool_key: tool_key) ||
|
|
Captain::ToolConfig.find_by(inbox: inbox, account: inbox.account, tool_key: tool_key)
|
|
@contact = conversation.contact
|
|
@additional_data = additional_data
|
|
end
|
|
|
|
def run
|
|
return failed_response('Tool not configured or disabled') unless @config&.is_enabled
|
|
return failed_response('Tool definition not found') unless @definition
|
|
|
|
start_time = Time.current
|
|
result = case @definition[:type]
|
|
when :http then execute_http
|
|
when :webhook then execute_webhook
|
|
when :internal then execute_internal
|
|
else failed_response('Unknown tool type')
|
|
end
|
|
|
|
duration = (Time.current - start_time) * 1000
|
|
result.merge(duration_ms: duration)
|
|
end
|
|
|
|
private
|
|
|
|
def execute_http
|
|
uri = URI(@definition[:url])
|
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
http.use_ssl = uri.scheme == 'https'
|
|
http.read_timeout = 8
|
|
|
|
request = Net::HTTP::Get.new(uri)
|
|
request['PLUG-PLAY-ID'] = @config.plug_play_id
|
|
request['PLUG-PLAY-TOKEN'] = @config.plug_play_token
|
|
|
|
response = http.request(request)
|
|
|
|
if response.is_a?(Net::HTTPSuccess)
|
|
parsed_body = parse_response(response.body)
|
|
{ success: true, body: parsed_body, status: response.code }
|
|
else
|
|
{ success: false, status: response.code, error: 'HTTP Request Failed' }
|
|
end
|
|
rescue StandardError => e
|
|
{ success: false, error: e.message }
|
|
end
|
|
|
|
def execute_internal
|
|
tool_class_name = "Captain::Tools::#{@tool_key.camelize}Tool"
|
|
# Safe constantize to avoid arbitrary code execution if key was untrusted (though it comes from Definitions)
|
|
klass = tool_class_name.safe_constantize
|
|
|
|
return failed_response("Tool Class #{tool_class_name} not found") unless klass
|
|
|
|
tool_instance = klass.new(@assistant, user: nil, conversation: @conversation)
|
|
|
|
# Merge additional data into params if the tool expects them
|
|
# Typically internal tools take a params hash.
|
|
# We assume tool_output from brain is passed as params or we build it here?
|
|
# ToolRunner.run signature: additional_data: { message: ... }
|
|
# The 'params' usuall come from the Brain's tool_input.
|
|
# But ToolRunner is called with `tool_key`... wait.
|
|
# AssistantChatService (line 48) calls ToolRunner with tool_key.
|
|
# But WHERE are the arguments (e.g. suite="Stilo")?
|
|
# A HA! JasmineBrain.decide returns `tool_key`.
|
|
# But does it return parameters?
|
|
# Looking at AssistantChatService again...
|
|
# brain_decision has .tool_key.
|
|
# It DOES NOT look like it passes parameters to ToolRunner!
|
|
# CHECK THIS before committing logic.
|
|
|
|
# Assumption based on `CheckAvailabilityTool` code: `execute(params = {})`.
|
|
# Params need to come from somewhere.
|
|
# In current AssistantChatService, `runner_result` is called without `tool_input`.
|
|
# This implies tools must parse the message THEMSELVES or parameters are missing?
|
|
# Let's verify AssistantChatService brain decision struct.
|
|
|
|
# For now, implementing the basic invocation. `execute` in BaseTool often parses context if params are empty.
|
|
# But CheckAvailabilityTool explicitly does `suite_category = params['suite']`.
|
|
# If params are empty, it fails.
|
|
|
|
# CRITICAL: Is JasmineBrain extracting parameters?
|
|
# If not, the tool must extract them from @conversation.
|
|
# But CheckAvailabilityTool uses `params`.
|
|
|
|
# Let's verify `JasmineBrain` in a later step if needed.
|
|
# For now, we pass `additional_data` as params which usually contains `message`.
|
|
# But `CheckAvailabilityTool` expects 'suite'.
|
|
|
|
# I will pass `additional_data` merged with any extracted parameters if available.
|
|
# But since I can't change the inputs to ToolRunner right here easily without finding the caller...
|
|
# I will assume the Tool executes with what it has.
|
|
|
|
# Wait, BaseTool has access to @conversation.
|
|
# CheckAvailabilityTool reads `params['suite']`.
|
|
# If JasmineBrain doesn't extract 'suite', this fails.
|
|
|
|
# RE-READING `GeneratePixTool` (Step 8838 modified):
|
|
# "Refactored... to operate on an active draft reservation, removing direct parameter requirements".
|
|
|
|
# RE-READING `CreateReservationIntentTool` (Step 8868):
|
|
# `suite_category = params['suite']`. It NEEDS params!
|
|
|
|
# RE-READING `AssistantChatService` (Step 8898):
|
|
# `brain_decision = Captain::Llm::JasmineBrain.decide(...)`.
|
|
# `ToolRunner.run(..., additional_data: { message: additional_data })`.
|
|
|
|
# Does `JasmineBrain` decision contain parameters?
|
|
# `brain_decision.tool_key`.
|
|
# If `JasmineBrain` is an LLM call providing JSON, it usually provides arguments.
|
|
# But `AssistantChatService` doesn't seem to pass them to `ToolRunner`.
|
|
|
|
# This might be ANOTHER bug.
|
|
# But first, let's enable the execution. CheckAvailabilityTool might parse the last message if params are empty?
|
|
# No, it checks `params['suite']`.
|
|
|
|
# I will inject a "Smart Parameter Extraction" if params are missing?
|
|
# OR: I assume `additional_data` CONTAINS the params?
|
|
# `AssistantChatService` passes `additional_data: { message: additional_message }`.
|
|
# That's just the message string.
|
|
|
|
# Warning: `CreateReservationIntentTool` will fail if it receives empty params.
|
|
# But `ToolRunner` must support it first.
|
|
|
|
# I will implement the execution.
|
|
# If I see "Missing suite" in the logs, I know `AssistantChatService` needs to pass parameters.
|
|
|
|
execution_result = tool_instance.execute(@additional_data.with_indifferent_access)
|
|
|
|
# Normalize result. Tool execute usually returns a String or Hash.
|
|
if execution_result.is_a?(String)
|
|
{ success: true, body: { message: execution_result } }
|
|
else
|
|
{ success: true, body: execution_result }
|
|
end
|
|
rescue StandardError => e
|
|
{ success: false, error: e.message }
|
|
end
|
|
|
|
def execute_webhook
|
|
url = @config.webhook_url
|
|
return failed_response('Webhook URL missing') if url.blank?
|
|
|
|
payload = build_webhook_payload
|
|
|
|
# Specific logic for escalate_human
|
|
if @tool_key == 'escalar_humano'
|
|
# Label logic handles in brain/response service if success
|
|
end
|
|
|
|
response = RestClient::Request.execute(
|
|
method: :post,
|
|
url: url,
|
|
payload: payload.to_json,
|
|
headers: { content_type: :json, accept: :json },
|
|
timeout: 8
|
|
)
|
|
|
|
{ success: true, status: response.code, body: { message: 'Webhook sent' } }
|
|
rescue StandardError => e
|
|
{ success: false, error: e.message }
|
|
end
|
|
|
|
def build_webhook_payload
|
|
base = {
|
|
conversation_id: @conversation.id,
|
|
contact_id: @contact.id,
|
|
event: @tool_key
|
|
}
|
|
|
|
# Specific payloads
|
|
if @tool_key == 'maria_fotos'
|
|
base.merge!(
|
|
suite_category: extract_suite_category || 'Indefinida',
|
|
message: 'User requested photos'
|
|
)
|
|
elsif @tool_key == 'escalar_humano'
|
|
base.merge!(reason: 'User requested human agent')
|
|
end
|
|
|
|
base
|
|
end
|
|
|
|
def extract_suite_category
|
|
# Simple extraction or ask JasmineBrain to help later?
|
|
# For V1, we try to grab from message content or nil
|
|
msg = @additional_data[:message]&.downcase || ''
|
|
return 'Alexa' if msg.include?('alexa')
|
|
return 'Stilo' if msg.include?('stilo')
|
|
return 'Hidromassagem' if msg.include?('hidro')
|
|
|
|
nil
|
|
end
|
|
|
|
def parse_response(body)
|
|
case @tool_key
|
|
when 'status_suites'
|
|
Captain::Tools::Parsers::StatusSuitesParser.parse(body)
|
|
else
|
|
begin
|
|
JSON.parse(body)
|
|
rescue StandardError
|
|
body
|
|
end
|
|
end
|
|
end
|
|
|
|
def failed_response(msg)
|
|
{ success: false, error: msg }
|
|
end
|
|
end
|
|
end
|
|
end
|