Initial commit: Add logistics and order_detail message types
Some checks failed
Lock Threads / action (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot EE docker images / merge (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot CE docker images / merge (push) Has been cancelled
Run Chatwoot CE spec / lint-backend (push) Has been cancelled
Run Chatwoot CE spec / lint-frontend (push) Has been cancelled
Run Chatwoot CE spec / frontend-tests (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (0, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (1, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (10, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (11, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (12, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (13, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (14, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (15, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (2, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (3, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (4, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (5, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (6, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (7, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (8, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (9, 16) (push) Has been cancelled
Run Linux nightly installer / nightly (push) Has been cancelled
Some checks failed
Lock Threads / action (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot EE docker images / merge (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot CE docker images / merge (push) Has been cancelled
Run Chatwoot CE spec / lint-backend (push) Has been cancelled
Run Chatwoot CE spec / lint-frontend (push) Has been cancelled
Run Chatwoot CE spec / frontend-tests (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (0, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (1, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (10, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (11, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (12, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (13, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (14, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (15, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (2, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (3, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (4, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (5, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (6, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (7, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (8, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (9, 16) (push) Has been cancelled
Run Linux nightly installer / nightly (push) Has been cancelled
- Add Logistics component with progress tracking - Add OrderDetail component for order information - Support data-driven steps and actions - Add blue color scale to widget SCSS - Fix node overflow and progress bar rendering issues - Add English translations for dashboard components Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
25
enterprise/lib/captain/prompt_renderer.rb
Normal file
25
enterprise/lib/captain/prompt_renderer.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
require 'liquid'
|
||||
|
||||
class Captain::PromptRenderer
|
||||
class << self
|
||||
def render(template_name, context = {})
|
||||
template = load_template(template_name)
|
||||
liquid_template = Liquid::Template.parse(template)
|
||||
liquid_template.render(stringify_keys(context))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_template(template_name)
|
||||
template_path = Rails.root.join('enterprise', 'lib', 'captain', 'prompts', "#{template_name}.liquid")
|
||||
|
||||
raise "Template not found: #{template_name}" unless File.exist?(template_path)
|
||||
|
||||
File.read(template_path)
|
||||
end
|
||||
|
||||
def stringify_keys(hash)
|
||||
hash.deep_stringify_keys
|
||||
end
|
||||
end
|
||||
end
|
||||
81
enterprise/lib/captain/prompts/assistant.liquid
Normal file
81
enterprise/lib/captain/prompts/assistant.liquid
Normal file
@@ -0,0 +1,81 @@
|
||||
# System Context
|
||||
You are part of Captain, a multi-agent AI system designed for seamless agent coordination and task execution. You can transfer conversations to specialized agents using handoff functions (e.g., `handoff_to_[agent_name]`). These transfers happen in the background - never mention or draw attention to them in your responses.
|
||||
|
||||
# Your Identity
|
||||
You are {{name}}, a helpful and knowledgeable assistant. Your role is to primarily act as a orchestrator handling multiple scenarios by using handoff tools. Your job also involves providing accurate information, assisting with tasks, and ensuring the customer get the help they need.
|
||||
|
||||
{{ description }}
|
||||
|
||||
Don't digress away from your instructions, and use all the available tools at your disposal for solving customer issues. If you are to state something factual about {{product_name}} ensure you source that information from the FAQs only. Use the `captain--tools--faq_lookup` tool for this.
|
||||
|
||||
{% if conversation || contact -%}
|
||||
# Current Context
|
||||
|
||||
Here's the metadata we have about the current conversation and the contact associated with it:
|
||||
|
||||
{% if conversation -%}
|
||||
{% render 'conversation' %}
|
||||
{% endif -%}
|
||||
|
||||
{% if contact -%}
|
||||
{% render 'contact' %}
|
||||
{% endif -%}
|
||||
{% endif -%}
|
||||
|
||||
{% if response_guidelines.size > 0 -%}
|
||||
# Response Guidelines
|
||||
Your responses should follow these guidelines:
|
||||
{% for guideline in response_guidelines -%}
|
||||
- {{ guideline }}
|
||||
- Be conversational but professional
|
||||
- Provide actionable information
|
||||
- Include relevant details from tool responses
|
||||
{% endfor %}
|
||||
{% endif -%}
|
||||
|
||||
{% if guardrails.size > 0 -%}
|
||||
# Guardrails
|
||||
Always respect these boundaries:
|
||||
{% for guardrail in guardrails -%}
|
||||
- {{ guardrail }}
|
||||
{% endfor %}
|
||||
{% endif -%}
|
||||
|
||||
# Decision Framework
|
||||
|
||||
## 1. Analyze the Request
|
||||
First, understand what the user is asking:
|
||||
- **Intent**: What are they trying to achieve?
|
||||
- **Type**: Is it a question, task, complaint, or request?
|
||||
- **Complexity**: Can you handle it or does it need specialized expertise?
|
||||
|
||||
## 2. Check for Specialized Scenarios First
|
||||
|
||||
Before using any tools, check if the request matches any of these scenarios. If it seems like a particular scenario matches, use the specific handoff tool to transfer the conversation to the specific agent. The following are the scenario agents that are available to you.
|
||||
|
||||
{% for scenario in scenarios -%}
|
||||
- {{ scenario.title }}: {{ scenario.description }}, use the `handoff_to_{{ scenario.key }}` tool to transfer the conversation to the {{ scenario.title }} agent.
|
||||
{% endfor %}
|
||||
If unclear, ask clarifying questions to determine if a scenario applies:
|
||||
|
||||
## 3. Handle the Request
|
||||
If no specialized scenario clearly matches, handle it yourself in the following way
|
||||
|
||||
### For Questions and Information Requests
|
||||
1. **First, check existing knowledge**: Use `captain--tools--faq_lookup` tool to search for relevant information
|
||||
2. **If not found in FAQs**: Try to ask clarifying questions to gather more information
|
||||
3. **If unable to answer**: Use `captain--tools--handoff` tool to transfer to a human expert
|
||||
|
||||
### For Complex or Unclear Requests
|
||||
1. **Ask clarifying questions**: Gather more information if needed
|
||||
2. **Break down complex tasks**: Handle step by step or hand off if too complex
|
||||
3. **Escalate when necessary**: Use `captain--tools--handoff` tool for issues beyond your capabilities
|
||||
|
||||
# Human Handoff Protocol
|
||||
Transfer to a human agent when:
|
||||
- User explicitly requests human assistance
|
||||
- You cannot find needed information after checking FAQs
|
||||
- The issue requires specialized knowledge or permissions you don't have
|
||||
- Multiple attempts to help have been unsuccessful
|
||||
|
||||
When using the `captain--tools--handoff` tool, provide a clear reason that helps the human agent understand the context.
|
||||
48
enterprise/lib/captain/prompts/scenario.liquid
Normal file
48
enterprise/lib/captain/prompts/scenario.liquid
Normal file
@@ -0,0 +1,48 @@
|
||||
# System context
|
||||
You are part of a multi-agent system where you've been handed off a conversation to handle a specific task. The handoff was seamless - the user is not aware of any transfer. Continue the conversation naturally.
|
||||
|
||||
# Your Role
|
||||
You are a specialized agent called "{{ title }}", your task is to handle the following scenario:
|
||||
|
||||
{{ instructions }}
|
||||
|
||||
If you believe the user's request is not within the scope of your role, you can assign this conversation back to the orchestrator agent using the `handoff_to_{{ assistant_name }}` tool
|
||||
|
||||
{% if conversation || contact %}
|
||||
# Current Context
|
||||
|
||||
Here's the metadata we have about the current conversation and the contact associated with it:
|
||||
|
||||
{% if conversation -%}
|
||||
{% render 'conversation' %}
|
||||
{% endif -%}
|
||||
|
||||
{% if contact -%}
|
||||
{% render 'contact' %}
|
||||
{% endif -%}
|
||||
{% endif -%}
|
||||
|
||||
|
||||
{% if response_guidelines.size > 0 -%}
|
||||
# Response Guidelines
|
||||
Your responses should follow these guidelines:
|
||||
{% for guideline in response_guidelines -%}
|
||||
- {{ guideline }}
|
||||
{% endfor %}
|
||||
{% endif -%}
|
||||
|
||||
{% if guardrails.size > 0 -%}
|
||||
# Guardrails
|
||||
Always respect these boundaries:
|
||||
{% for guardrail in guardrails -%}
|
||||
- {{ guardrail }}
|
||||
{% endfor %}
|
||||
{% endif -%}
|
||||
|
||||
{% if tools.size > 0 -%}
|
||||
# Available Tools
|
||||
You have access to these tools:
|
||||
{% for tool in tools -%}
|
||||
- {{ tool.id }}: {{ tool.description }}
|
||||
{% endfor %}
|
||||
{%- endif %}
|
||||
17
enterprise/lib/captain/prompts/snippets/contact.liquid
Normal file
17
enterprise/lib/captain/prompts/snippets/contact.liquid
Normal file
@@ -0,0 +1,17 @@
|
||||
# Contact Information
|
||||
- Contact ID: {{ contact.id }}
|
||||
- Name: {{ contact.name || "Unknown" }}
|
||||
- Email: {{ contact.email || "None" }}
|
||||
- Phone: {{ contact.phone_number || "None" }}
|
||||
- Identifier: {{ contact.identifier || "None" }}
|
||||
- Type: {{ contact.contact_type || "visitor" }}
|
||||
{% if contact.custom_attributes -%}
|
||||
{% for attribute in contact.custom_attributes -%}
|
||||
- {{ attribute[0] }}: {{ attribute[1] }}
|
||||
{% endfor -%}
|
||||
{% endif -%}
|
||||
{% if contact.additional_attributes -%}
|
||||
{% for attribute in contact.additional_attributes -%}
|
||||
- {{ attribute[0] }}: {{ attribute[1] }}
|
||||
{% endfor -%}
|
||||
{% endif -%}
|
||||
18
enterprise/lib/captain/prompts/snippets/conversation.liquid
Normal file
18
enterprise/lib/captain/prompts/snippets/conversation.liquid
Normal file
@@ -0,0 +1,18 @@
|
||||
# Current Conversation Context
|
||||
- Conversation ID: {{ conversation.display_id }}
|
||||
- Contact ID: {{ conversation.contact_id }}
|
||||
- Status: {{ conversation.status }}
|
||||
- Priority: {{ conversation.priority || "None" }}
|
||||
{% if conversation.label_list.size > 0 -%}
|
||||
- Labels: {{ conversation.label_list | join: ", " }}
|
||||
{% endif -%}
|
||||
{% if conversation.custom_attributes -%}
|
||||
{% for attribute in conversation.custom_attributes -%}
|
||||
- {{ attribute[0] }}: {{ attribute[1] }}
|
||||
{% endfor -%}
|
||||
{% endif -%}
|
||||
{% if conversation.additional_attributes -%}
|
||||
{% for attribute in conversation.additional_attributes -%}
|
||||
- {{ attribute[0] }}: {{ attribute[1] }}
|
||||
{% endfor -%}
|
||||
{% endif -%}
|
||||
6
enterprise/lib/captain/response_schema.rb
Normal file
6
enterprise/lib/captain/response_schema.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
# TODO: Wrap the schema lib under ai-agents
|
||||
# So we can extend it as Agents::Schema
|
||||
class Captain::ResponseSchema < RubyLLM::Schema
|
||||
string :response, description: 'The message to send to the user'
|
||||
string :reasoning, description: "Agent's thought process"
|
||||
end
|
||||
26
enterprise/lib/captain/tools/add_contact_note_tool.rb
Normal file
26
enterprise/lib/captain/tools/add_contact_note_tool.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
class Captain::Tools::AddContactNoteTool < Captain::Tools::BasePublicTool
|
||||
description 'Add a note to a contact profile'
|
||||
param :note, type: 'string', desc: 'The note content to add to the contact'
|
||||
|
||||
def perform(tool_context, note:)
|
||||
contact = find_contact(tool_context.state)
|
||||
return 'Contact not found' unless contact
|
||||
|
||||
return 'Note content is required' if note.blank?
|
||||
|
||||
log_tool_usage('add_contact_note', { contact_id: contact.id, note_length: note.length })
|
||||
|
||||
create_contact_note(contact, note)
|
||||
"Note added successfully to contact #{contact.name} (ID: #{contact.id})"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_contact_note(contact, note)
|
||||
contact.notes.create!(content: note)
|
||||
end
|
||||
|
||||
def permissions
|
||||
%w[contact_manage]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
class Captain::Tools::AddLabelToConversationTool < Captain::Tools::BasePublicTool
|
||||
description 'Add a label to a conversation'
|
||||
param :label_name, type: 'string', desc: 'The name of the label to add'
|
||||
|
||||
def perform(tool_context, label_name:)
|
||||
conversation = find_conversation(tool_context.state)
|
||||
return 'Conversation not found' unless conversation
|
||||
|
||||
label_name = label_name&.strip&.downcase
|
||||
return 'Label name is required' if label_name.blank?
|
||||
|
||||
label = find_label(label_name)
|
||||
return 'Label not found' unless label
|
||||
|
||||
add_label_to_conversation(conversation, label_name)
|
||||
|
||||
log_tool_usage('added_label', conversation_id: conversation.id, label: label_name)
|
||||
|
||||
"Label '#{label_name}' added to conversation ##{conversation.display_id}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_label(label_name)
|
||||
account_scoped(Label).find_by(title: label_name)
|
||||
end
|
||||
|
||||
def add_label_to_conversation(conversation, label_name)
|
||||
conversation.add_labels(label_name)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to add label to conversation: #{e.message}"
|
||||
raise
|
||||
end
|
||||
end
|
||||
33
enterprise/lib/captain/tools/add_private_note_tool.rb
Normal file
33
enterprise/lib/captain/tools/add_private_note_tool.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
class Captain::Tools::AddPrivateNoteTool < Captain::Tools::BasePublicTool
|
||||
description 'Add a private note to a conversation'
|
||||
param :note, type: 'string', desc: 'The private note content'
|
||||
|
||||
def perform(tool_context, note:)
|
||||
conversation = find_conversation(tool_context.state)
|
||||
return 'Conversation not found' unless conversation
|
||||
|
||||
return 'Note content is required' if note.blank?
|
||||
|
||||
log_tool_usage('add_private_note', { conversation_id: conversation.id, note_length: note.length })
|
||||
create_private_note(conversation, note)
|
||||
|
||||
'Private note added successfully'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_private_note(conversation, note)
|
||||
conversation.messages.create!(
|
||||
account: @assistant.account,
|
||||
inbox: conversation.inbox,
|
||||
sender: @assistant,
|
||||
message_type: :outgoing,
|
||||
content: note,
|
||||
private: true
|
||||
)
|
||||
end
|
||||
|
||||
def permissions
|
||||
%w[conversation_manage conversation_unassigned_manage conversation_participating_manage]
|
||||
end
|
||||
end
|
||||
45
enterprise/lib/captain/tools/base_public_tool.rb
Normal file
45
enterprise/lib/captain/tools/base_public_tool.rb
Normal file
@@ -0,0 +1,45 @@
|
||||
require 'agents'
|
||||
|
||||
class Captain::Tools::BasePublicTool < Agents::Tool
|
||||
def initialize(assistant)
|
||||
@assistant = assistant
|
||||
super()
|
||||
end
|
||||
|
||||
def active?
|
||||
# Public tools are always active
|
||||
true
|
||||
end
|
||||
|
||||
def permissions
|
||||
# Override in subclasses to specify required permissions
|
||||
# Returns empty array for public tools (no permissions required)
|
||||
[]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def account_scoped(model_class)
|
||||
model_class.where(account_id: @assistant.account_id)
|
||||
end
|
||||
|
||||
def find_conversation(state)
|
||||
conversation_id = state&.dig(:conversation, :id)
|
||||
return nil unless conversation_id
|
||||
|
||||
account_scoped(::Conversation).find_by(id: conversation_id)
|
||||
end
|
||||
|
||||
def find_contact(state)
|
||||
contact_id = state&.dig(:contact, :id)
|
||||
return nil unless contact_id
|
||||
|
||||
account_scoped(::Contact).find_by(id: contact_id)
|
||||
end
|
||||
|
||||
def log_tool_usage(action, details = {})
|
||||
Rails.logger.info do
|
||||
"#{self.class.name}: #{action} for assistant #{@assistant&.id} - #{details.inspect}"
|
||||
end
|
||||
end
|
||||
end
|
||||
48
enterprise/lib/captain/tools/faq_lookup_tool.rb
Normal file
48
enterprise/lib/captain/tools/faq_lookup_tool.rb
Normal file
@@ -0,0 +1,48 @@
|
||||
class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool
|
||||
description 'Search FAQ responses using semantic similarity to find relevant answers'
|
||||
param :query, type: 'string', desc: 'The question or topic to search for in the FAQ database'
|
||||
|
||||
def perform(_tool_context, query:)
|
||||
log_tool_usage('searching', { query: query })
|
||||
|
||||
# Use existing vector search on approved responses
|
||||
responses = @assistant.responses.approved.search(query).to_a
|
||||
|
||||
if responses.empty?
|
||||
log_tool_usage('no_results', { query: query })
|
||||
"No relevant FAQs found for: #{query}"
|
||||
else
|
||||
log_tool_usage('found_results', { query: query, count: responses.size })
|
||||
format_responses(responses)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_responses(responses)
|
||||
responses.map { |response| format_response(response) }.join
|
||||
end
|
||||
|
||||
def format_response(response)
|
||||
formatted_response = "
|
||||
Question: #{response.question}
|
||||
Answer: #{response.answer}
|
||||
"
|
||||
if should_show_source?(response)
|
||||
formatted_response += "
|
||||
Source: #{response.documentable.external_link}
|
||||
"
|
||||
end
|
||||
|
||||
formatted_response
|
||||
end
|
||||
|
||||
def should_show_source?(response)
|
||||
return false if response.documentable.blank?
|
||||
return false unless response.documentable.try(:external_link)
|
||||
|
||||
# Don't show source if it's a PDF placeholder
|
||||
external_link = response.documentable.external_link
|
||||
!external_link.start_with?('PDF:')
|
||||
end
|
||||
end
|
||||
59
enterprise/lib/captain/tools/handoff_tool.rb
Normal file
59
enterprise/lib/captain/tools/handoff_tool.rb
Normal file
@@ -0,0 +1,59 @@
|
||||
class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
|
||||
description 'Hand off the conversation to a human agent when unable to assist further'
|
||||
param :reason, type: 'string', desc: 'The reason why handoff is needed (optional)', required: false
|
||||
|
||||
def perform(tool_context, reason: nil)
|
||||
conversation = find_conversation(tool_context.state)
|
||||
return 'Conversation not found' unless conversation
|
||||
|
||||
# Log the handoff with reason
|
||||
log_tool_usage('tool_handoff', {
|
||||
conversation_id: conversation.id,
|
||||
reason: reason || 'Agent requested handoff'
|
||||
})
|
||||
|
||||
# Use existing handoff mechanism from ResponseBuilderJob
|
||||
trigger_handoff(conversation, reason)
|
||||
|
||||
"Conversation handed off to human support team#{" (Reason: #{reason})" if reason}"
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
'Failed to handoff conversation'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def trigger_handoff(conversation, reason)
|
||||
# post the reason as a private note
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
private: true,
|
||||
sender: @assistant,
|
||||
account: conversation.account,
|
||||
inbox: conversation.inbox,
|
||||
content: reason
|
||||
)
|
||||
|
||||
# Trigger the bot handoff (sets status to open + dispatches events)
|
||||
conversation.bot_handoff!
|
||||
|
||||
# Send out of office message if applicable (since template messages were suppressed while Captain was handling)
|
||||
send_out_of_office_message_if_applicable(conversation)
|
||||
end
|
||||
|
||||
def send_out_of_office_message_if_applicable(conversation)
|
||||
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
|
||||
end
|
||||
|
||||
# TODO: Future enhancement - Add team assignment capability
|
||||
# This tool could be enhanced to:
|
||||
# 1. Accept team_id parameter for routing to specific teams
|
||||
# 2. Set conversation priority based on handoff reason
|
||||
# 3. Add metadata for intelligent agent assignment
|
||||
# 4. Support escalation levels (L1 -> L2 -> L3)
|
||||
#
|
||||
# Example future signature:
|
||||
# param :team_id, type: 'string', desc: 'ID of team to assign conversation to', required: false
|
||||
# param :priority, type: 'string', desc: 'Priority level (low/medium/high/urgent)', required: false
|
||||
# param :escalation_level, type: 'string', desc: 'Support level (L1/L2/L3)', required: false
|
||||
end
|
||||
112
enterprise/lib/captain/tools/http_tool.rb
Normal file
112
enterprise/lib/captain/tools/http_tool.rb
Normal file
@@ -0,0 +1,112 @@
|
||||
require 'agents'
|
||||
|
||||
class Captain::Tools::HttpTool < Agents::Tool
|
||||
def initialize(assistant, custom_tool)
|
||||
@assistant = assistant
|
||||
@custom_tool = custom_tool
|
||||
super()
|
||||
end
|
||||
|
||||
def active?
|
||||
@custom_tool.enabled?
|
||||
end
|
||||
|
||||
def perform(tool_context, **params)
|
||||
url = @custom_tool.build_request_url(params)
|
||||
body = @custom_tool.build_request_body(params)
|
||||
|
||||
response = execute_http_request(url, body, tool_context)
|
||||
@custom_tool.format_response(response.body)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("HttpTool execution error for #{@custom_tool.slug}: #{e.class} - #{e.message}")
|
||||
'An error occurred while executing the request'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
PRIVATE_IP_RANGES = [
|
||||
IPAddr.new('127.0.0.0/8'), # IPv4 Loopback
|
||||
IPAddr.new('10.0.0.0/8'), # IPv4 Private network
|
||||
IPAddr.new('172.16.0.0/12'), # IPv4 Private network
|
||||
IPAddr.new('192.168.0.0/16'), # IPv4 Private network
|
||||
IPAddr.new('169.254.0.0/16'), # IPv4 Link-local
|
||||
IPAddr.new('::1'), # IPv6 Loopback
|
||||
IPAddr.new('fc00::/7'), # IPv6 Unique local addresses
|
||||
IPAddr.new('fe80::/10') # IPv6 Link-local
|
||||
].freeze
|
||||
|
||||
# Limit response size to prevent memory exhaustion and match LLM token limits
|
||||
# 1MB of text ≈ 250K tokens, which exceeds most LLM context windows
|
||||
MAX_RESPONSE_SIZE = 1.megabyte
|
||||
|
||||
def execute_http_request(url, body, tool_context)
|
||||
uri = URI.parse(url)
|
||||
|
||||
# Check if resolved IP is private
|
||||
check_private_ip!(uri.host)
|
||||
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = uri.scheme == 'https'
|
||||
http.read_timeout = 30
|
||||
http.open_timeout = 10
|
||||
http.max_retries = 0 # Disable redirects
|
||||
|
||||
request = build_http_request(uri, body)
|
||||
apply_authentication(request)
|
||||
apply_metadata_headers(request, tool_context)
|
||||
|
||||
response = http.request(request)
|
||||
|
||||
raise "HTTP request failed with status #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
||||
|
||||
validate_response!(response)
|
||||
|
||||
response
|
||||
end
|
||||
|
||||
def check_private_ip!(hostname)
|
||||
ip_address = IPAddr.new(Resolv.getaddress(hostname))
|
||||
|
||||
raise 'Request blocked: hostname resolves to private IP address' if PRIVATE_IP_RANGES.any? { |range| range.include?(ip_address) }
|
||||
rescue Resolv::ResolvError, SocketError => e
|
||||
raise "DNS resolution failed: #{e.message}"
|
||||
end
|
||||
|
||||
def validate_response!(response)
|
||||
content_length = response['content-length']&.to_i
|
||||
if content_length && content_length > MAX_RESPONSE_SIZE
|
||||
raise "Response size #{content_length} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes"
|
||||
end
|
||||
|
||||
return unless response.body && response.body.bytesize > MAX_RESPONSE_SIZE
|
||||
|
||||
raise "Response body size #{response.body.bytesize} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes"
|
||||
end
|
||||
|
||||
def build_http_request(uri, body)
|
||||
if @custom_tool.http_method == 'POST'
|
||||
request = Net::HTTP::Post.new(uri.request_uri)
|
||||
if body
|
||||
request.body = body
|
||||
request['Content-Type'] = 'application/json'
|
||||
end
|
||||
else
|
||||
request = Net::HTTP::Get.new(uri.request_uri)
|
||||
end
|
||||
request
|
||||
end
|
||||
|
||||
def apply_authentication(request)
|
||||
headers = @custom_tool.build_auth_headers
|
||||
headers.each { |key, value| request[key] = value }
|
||||
|
||||
credentials = @custom_tool.build_basic_auth_credentials
|
||||
request.basic_auth(*credentials) if credentials
|
||||
end
|
||||
|
||||
def apply_metadata_headers(request, tool_context)
|
||||
state = tool_context&.state || {}
|
||||
metadata_headers = @custom_tool.build_metadata_headers(state)
|
||||
metadata_headers.each { |key, value| request[key] = value }
|
||||
end
|
||||
end
|
||||
50
enterprise/lib/captain/tools/update_priority_tool.rb
Normal file
50
enterprise/lib/captain/tools/update_priority_tool.rb
Normal file
@@ -0,0 +1,50 @@
|
||||
class Captain::Tools::UpdatePriorityTool < Captain::Tools::BasePublicTool
|
||||
description 'Update the priority of a conversation'
|
||||
param :priority, type: 'string', desc: 'The priority level: low, medium, high, urgent, or nil to remove priority'
|
||||
|
||||
def perform(tool_context, priority:)
|
||||
@conversation = find_conversation(tool_context.state)
|
||||
return 'Conversation not found' unless @conversation
|
||||
|
||||
@normalized_priority = normalize_priority(priority)
|
||||
return "Invalid priority. Valid options: #{valid_priority_options}" unless valid_priority?(@normalized_priority)
|
||||
|
||||
log_tool_usage('update_priority', { conversation_id: @conversation.id, priority: priority })
|
||||
|
||||
execute_priority_update
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def execute_priority_update
|
||||
update_conversation_priority(@conversation, @normalized_priority)
|
||||
priority_text = @normalized_priority || 'none'
|
||||
"Priority updated to '#{priority_text}' for conversation ##{@conversation.display_id}"
|
||||
end
|
||||
|
||||
def normalize_priority(priority)
|
||||
return nil if priority == 'nil' || priority.blank?
|
||||
|
||||
priority.downcase
|
||||
end
|
||||
|
||||
def valid_priority?(priority)
|
||||
valid_priorities.include?(priority)
|
||||
end
|
||||
|
||||
def valid_priorities
|
||||
@valid_priorities ||= [nil] + Conversation.priorities.keys
|
||||
end
|
||||
|
||||
def valid_priority_options
|
||||
(valid_priorities.compact + ['nil']).join(', ')
|
||||
end
|
||||
|
||||
def update_conversation_priority(conversation, priority)
|
||||
conversation.update!(priority: priority)
|
||||
end
|
||||
|
||||
def permissions
|
||||
%w[conversation_manage conversation_unassigned_manage conversation_participating_manage]
|
||||
end
|
||||
end
|
||||
2
enterprise/lib/enterprise.rb
Normal file
2
enterprise/lib/enterprise.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module Enterprise
|
||||
end
|
||||
@@ -0,0 +1,82 @@
|
||||
module Enterprise::Integrations::OpenaiProcessorService
|
||||
ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion label_suggestion fix_spelling_grammar shorten expand
|
||||
make_friendly make_formal simplify].freeze
|
||||
CACHEABLE_EVENTS = %w[label_suggestion].freeze
|
||||
|
||||
def label_suggestion_message
|
||||
payload = label_suggestion_body
|
||||
return nil if payload.blank?
|
||||
|
||||
response = make_api_call(label_suggestion_body)
|
||||
|
||||
return response if response[:error].present?
|
||||
|
||||
# LLMs are not deterministic, so this is bandaid solution
|
||||
# To what you ask? Sometimes, the response includes
|
||||
# "Labels:" in it's response in some format. This is a hacky way to remove it
|
||||
# TODO: Fix with with a better prompt
|
||||
{ message: response[:message] ? response[:message].gsub(/^(label|labels):/i, '') : '' }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def labels_with_messages
|
||||
return nil unless valid_conversation?(conversation)
|
||||
|
||||
labels = hook.account.labels.pluck(:title).join(', ')
|
||||
character_count = labels.length
|
||||
|
||||
messages = init_messages_body(false)
|
||||
add_messages_until_token_limit(conversation, messages, false, character_count)
|
||||
|
||||
return nil if messages.blank? || labels.blank?
|
||||
|
||||
"Messages:\n#{messages}\nLabels:\n#{labels}"
|
||||
end
|
||||
|
||||
def valid_conversation?(conversation)
|
||||
return false if conversation.nil?
|
||||
return false if conversation.messages.incoming.count < 3
|
||||
|
||||
# Think Mark think, at this point the conversation is beyond saving
|
||||
return false if conversation.messages.count > 100
|
||||
|
||||
# if there are more than 20 messages, only trigger this if the last message is from the client
|
||||
return false if conversation.messages.count > 20 && !conversation.messages.last.incoming?
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def summarize_body
|
||||
{
|
||||
model: self.class::GPT_MODEL,
|
||||
messages: [
|
||||
{ role: 'system',
|
||||
content: prompt_from_file('summary', enterprise: true) },
|
||||
{ role: 'user', content: conversation_messages }
|
||||
]
|
||||
}.to_json
|
||||
end
|
||||
|
||||
def label_suggestion_body
|
||||
return unless label_suggestions_enabled?
|
||||
|
||||
content = labels_with_messages
|
||||
return value_from_cache if content.blank?
|
||||
|
||||
{
|
||||
model: self.class::GPT_MODEL,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: prompt_from_file('label_suggestion', enterprise: true)
|
||||
},
|
||||
{ role: 'user', content: content }
|
||||
]
|
||||
}.to_json
|
||||
end
|
||||
|
||||
def label_suggestions_enabled?
|
||||
hook.settings['label_suggestion'].present?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1 @@
|
||||
Your role is as an assistant to a customer support agent. You will be provided with a transcript of a conversation between a customer and the support agent, along with a list of potential labels. Your task is to analyze the conversation and select the two labels from the given list that most accurately represent the themes or issues discussed. Ensure you preserve the exact casing of the labels as they are provided in the list. Do not create new labels; only choose from those provided. Once you have made your selections, please provide your response as a comma-separated list of the provided labels. Remember, your response should only contain the labels you\'ve selected,in their original casing, and nothing else.
|
||||
@@ -0,0 +1,28 @@
|
||||
As an AI-powered summarization tool, your task is to condense lengthy interactions between customer support agents and customers into brief, digestible summaries. The objective of these summaries is to provide a quick overview, enabling any agent, even those without prior context, to grasp the essence of the conversation promptly.
|
||||
|
||||
Make sure you strongly adhere to the following rules when generating the summary
|
||||
|
||||
1. Be brief and concise. The shorter the summary the better.
|
||||
2. Aim to summarize the conversation in approximately 200 words, formatted as multiple small paragraphs that are easier to read.
|
||||
3. Describe the customer intent in around 50 words.
|
||||
4. Remove information that is not directly relevant to the customer's problem or the agent's solution. For example, personal anecdotes, small talk, etc.
|
||||
5. Don't include segments of the conversation that didn't contribute meaningful content, like greetings or farewell.
|
||||
6. The 'Action Items' should be a bullet list, arranged in order of priority if possible.
|
||||
7. 'Action Items' should strictly encapsulate tasks committed to by the agent or left incomplete. Any suggestions made by the agent should not be included.
|
||||
8. The 'Action Items' should be brief and concise
|
||||
9. Mark important words or parts of sentences as bold.
|
||||
10. Apply markdown syntax to format any included code, using backticks.
|
||||
11. Include a section for "Follow-up Items" or "Open Questions" if there are any unresolved issues or outstanding questions.
|
||||
12. If any section does not have any content, remove that section and the heading from the response
|
||||
13. Do not insert your own opinions about the conversation.
|
||||
|
||||
|
||||
Reply in the user's language, as a markdown of the following format.
|
||||
|
||||
**Customer Intent**
|
||||
|
||||
**Conversation Summary**
|
||||
|
||||
**Action Items**
|
||||
|
||||
**Follow-up Items**
|
||||
44
enterprise/lib/tasks/search.rake
Normal file
44
enterprise/lib/tasks/search.rake
Normal file
@@ -0,0 +1,44 @@
|
||||
namespace :search do
|
||||
desc 'Reindex messages for all accounts'
|
||||
task all: :environment do
|
||||
next unless check_opensearch_config
|
||||
|
||||
puts 'Starting reindex for all accounts...'
|
||||
account_count = Account.count
|
||||
puts "Found #{account_count} accounts"
|
||||
|
||||
Account.find_each.with_index(1) do |account, index|
|
||||
puts "[#{index}/#{account_count}] Reindexing messages for account #{account.id}"
|
||||
reindex_account(account)
|
||||
end
|
||||
|
||||
puts 'Reindex task queued for all accounts'
|
||||
end
|
||||
|
||||
desc 'Reindex messages for a specific account: rake search:account ACCOUNT_ID=1'
|
||||
task account: :environment do
|
||||
next unless check_opensearch_config
|
||||
|
||||
account_id = ENV.fetch('ACCOUNT_ID', nil)
|
||||
account = Account.find_by(id: account_id)
|
||||
if account.nil?
|
||||
puts 'Please provide a valid account ID. Account not found'
|
||||
next
|
||||
end
|
||||
puts "Reindexing messages for account #{account.id}"
|
||||
reindex_account(account)
|
||||
end
|
||||
end
|
||||
|
||||
def check_opensearch_config
|
||||
if ENV['OPENSEARCH_URL'].blank?
|
||||
puts 'Skipping reindex as OPENSEARCH_URL is not configured'
|
||||
return false
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
def reindex_account(account)
|
||||
Messages::ReindexService.new(account: account).perform
|
||||
puts "Reindex task queued for account #{account.id}"
|
||||
end
|
||||
Reference in New Issue
Block a user