feat(ee): Add Captain features (#10665)

Migration Guide: https://chwt.app/v4/migration

This PR imports all the work related to Captain into the EE codebase. Captain represents the AI-based features in Chatwoot and includes the following key components:

- Assistant: An assistant has a persona, the product it would be trained on. At the moment, the data at which it is trained is from websites. Future integrations on Notion documents, PDF etc. This PR enables connecting an assistant to an inbox. The assistant would run the conversation every time before transferring it to an agent.
- Copilot for Agents: When an agent is supporting a customer, we will be able to offer additional help to lookup some data or fetch information from integrations etc via copilot.
- Conversation FAQ generator: When a conversation is resolved, the Captain integration would identify questions which were not in the knowledge base.
- CRM memory: Learns from the conversations and identifies important information about the contact.

---------

Co-authored-by: Vishnu Narayanan <vishnu@chatwoot.com>
Co-authored-by: Sojan <sojan@pepalo.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
Pranav
2025-01-14 16:15:47 -08:00
committed by GitHub
parent 7b31b5ad6e
commit d070743383
184 changed files with 6666 additions and 2242 deletions

View File

@@ -0,0 +1,132 @@
require 'openai'
class Captain::Agent
attr_reader :name, :tools, :prompt, :persona, :goal, :secrets
def initialize(name:, config:)
@name = name
@prompt = construct_prompt(config)
@tools = prepare_tools(config[:tools] || [])
@messages = config[:messages] || []
@max_iterations = config[:max_iterations] || 10
@llm = Captain::LlmService.new(api_key: config[:secrets][:OPENAI_API_KEY])
@logger = Rails.logger
@logger.info(@prompt)
end
def execute(input, context)
setup_messages(input, context)
result = {}
@max_iterations.times do |iteration|
push_to_messages(role: 'system', content: 'Provide a final answer') if iteration == @max_iterations - 1
result = @llm.call(@messages, functions)
handle_llm_result(result)
break if result[:stop]
end
result[:output]
end
def register_tool(tool)
@tools << tool
end
private
def setup_messages(input, context)
if @messages.empty?
push_to_messages({ role: 'system', content: @prompt })
push_to_messages({ role: 'assistant', content: context }) if context.present?
end
push_to_messages({ role: 'user', content: input })
end
def handle_llm_result(result)
if result[:tool_call]
tool_result = execute_tool(result[:tool_call])
push_to_messages({ role: 'assistant', content: tool_result })
else
push_to_messages({ role: 'assistant', content: result[:output] })
end
result[:output]
end
def execute_tool(tool_call)
function_name = tool_call['function']['name']
arguments = JSON.parse(tool_call['function']['arguments'])
tool = @tools.find { |t| t.name == function_name }
tool.execute(arguments, {})
rescue StandardError => e
"Tool execution failed: #{e.message}"
end
def construct_prompt(config)
return config[:prompt] if config[:prompt]
"
Persona: #{config[:persona]}
Objective: #{config[:goal]}
Guidelines:
- Work diligently until the stated objective is achieved.
- Utilize only the provided tools for solving the task. Do not make up names of the functions
- Set 'stop: true' when the objective is complete.
- DO NOT provide tool_call as final answer
- If you have enough information to provide the details to the user, prepare a final result collecting all the information you have.
Output Structure:
If you find a function, that can be used, directly call the function.
When providing the final answer, use the JSON format:
{
'thought_process': 'Describe the reasoning and steps that led to the final result.',
'result': 'The complete answer in text form.',
'stop': true
}
"
end
def prepare_tools(tools = [])
tools.map do |_, tool|
Captain::Tool.new(
name: tool['name'],
config: {
description: tool['description'],
properties: tool['properties'],
secrets: tool['secrets'],
implementation: tool['implementation']
}
)
end
end
def functions
@tools.map do |tool|
properties = {}
tool.properties.each do |property_name, property_details|
properties[property_name] = {
type: property_details[:type],
description: property_details[:description]
}
end
required = tool.properties.select { |_, details| details[:required] == true }.keys
{
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: { type: 'object', properties: properties, required: required }
}
}
end
end
def push_to_messages(message)
@logger.info("Message: #{message}")
@messages << message
end
end

View File

@@ -0,0 +1,63 @@
require 'openai'
class Captain::LlmService
def initialize(config)
@client = OpenAI::Client.new(access_token: config[:api_key]) do |f|
f.response :logger, Logger.new($stdout), bodies: true
end
@logger = Rails.logger
end
def call(messages, functions = [])
openai_params = {
model: 'gpt-4o-mini',
response_format: { type: 'json_object' },
messages: messages
}
openai_params[:tools] = functions if functions.any?
response = @client.chat(parameters: openai_params)
handle_response(response)
rescue StandardError => e
handle_error(e)
end
private
def handle_response(response)
if response['choices'][0]['message']['tool_calls']
handle_tool_calls(response)
else
handle_direct_response(response)
end
end
def handle_tool_calls(response)
tool_call = response['choices'][0]['message']['tool_calls'][0]
{
tool_call: tool_call,
output: nil,
stop: false
}
end
def handle_direct_response(response)
content = response.dig('choices', 0, 'message', 'content').strip
parsed = JSON.parse(content)
{
output: parsed['result'] || parsed['thought_process'],
stop: parsed['stop'] || false
}
rescue JSON::ParserError => e
handle_error(e, content)
end
def handle_error(error, content = nil)
@logger.error("LLM call failed: #{error.message}")
@logger.error(error.backtrace.join("\n"))
@logger.error("Content: #{content}") if content
{ output: 'Error occurred, retrying', stop: false }
end
end

View File

@@ -0,0 +1,66 @@
class Captain::Tool
class InvalidImplementationError < StandardError; end
class InvalidSecretsError < StandardError; end
class ExecutionError < StandardError; end
REQUIRED_PROPERTIES = %w[name description properties secrets].freeze
attr_reader :name, :description, :properties, :secrets, :implementation, :memory
def initialize(name:, config:)
@name = name
@description = config[:description]
@properties = config[:properties]
@secrets = config[:secrets] || []
@implementation = config[:implementation]
@memory = config[:memory] || {}
end
def register_method(&block)
@implementation = block
end
def execute(input, provided_secrets = {})
validate_secrets!(provided_secrets)
validate_input!(input)
raise ExecutionError, 'No implementation registered' unless @implementation
instance_exec(input, provided_secrets, memory, &@implementation)
rescue StandardError => e
raise ExecutionError, "Execution failed: #{e.message}"
end
private
def validate_config!(config)
missing_keys = REQUIRED_PROPERTIES - config.keys
return if missing_keys.empty?
raise InvalidImplementationError,
"Missing required properties: #{missing_keys.join(', ')}"
end
def validate_secrets!(provided_secrets)
required_secrets = secrets.map!(&:to_sym)
missing_secrets = required_secrets - provided_secrets.keys
return if missing_secrets.empty?
raise InvalidSecretsError, "Missing required secrets: #{missing_secrets.join(', ')}"
end
def validate_input!(input)
properties.each do |property, constraints|
validate_property!(input, property, constraints)
end
end
def validate_property!(input, property, constraints)
value = input[property.to_sym]
raise ArgumentError, "Missing required property: #{property}" if constraints['required'] && value.nil?
true
end
end

View File

@@ -3,24 +3,6 @@ module Enterprise::Integrations::OpenaiProcessorService
make_friendly make_formal simplify].freeze
CACHEABLE_EVENTS = %w[label_suggestion].freeze
def reply_suggestion_message
return super unless conversation.inbox.response_bot_enabled?
messages = conversation_messages(in_array_format: true)
last_message = messages.pop
robin_response = ChatGpt.new(
Enterprise::MessageTemplates::ResponseBotService.response_sections(last_message[:content], conversation.inbox)
).generate_response(
last_message[:content], messages, last_message[:role]
)
message_content = robin_response['response']
if robin_response['context_ids'].present?
message_content += Enterprise::MessageTemplates::ResponseBotService.generate_sources_section(robin_response['context_ids'])
end
message_content
end
def label_suggestion_message
payload = label_suggestion_body
return nil if payload.blank?