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

@@ -9,15 +9,21 @@
# updated_at :datetime not null
# article_id :bigint not null
#
# Indexes
#
# index_article_embeddings_on_embedding (embedding) USING ivfflat
#
class ArticleEmbedding < ApplicationRecord
belongs_to :article
has_neighbors :embedding, normalize: true
before_save :update_response_embedding
after_commit :update_response_embedding
private
def update_response_embedding
self.embedding = Openai::EmbeddingsService.new.get_embedding(term, 'text-embedding-3-small')
return unless saved_change_to_term? || embedding.nil?
Captain::Llm::UpdateEmbeddingJob.perform_later(self, term)
end
end

View File

@@ -0,0 +1,37 @@
# == Schema Information
#
# Table name: captain_assistants
#
# id :bigint not null, primary key
# config :jsonb not null
# description :string
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_captain_assistants_on_account_id (account_id)
#
class Captain::Assistant < ApplicationRecord
self.table_name = 'captain_assistants'
belongs_to :account
has_many :documents, class_name: 'Captain::Document', dependent: :destroy_async
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy_async
has_many :captain_inboxes,
class_name: 'CaptainInbox',
foreign_key: :captain_assistant_id,
dependent: :destroy_async
has_many :inboxes,
through: :captain_inboxes
validates :name, presence: true
validates :description, presence: true
validates :account_id, presence: true
scope :ordered, -> { order(created_at: :desc) }
scope :for_account, ->(account_id) { where(account_id: account_id) }
end

View File

@@ -0,0 +1,57 @@
# == Schema Information
#
# Table name: captain_assistant_responses
#
# id :bigint not null, primary key
# answer :text not null
# embedding :vector(1536)
# question :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# assistant_id :bigint not null
# document_id :bigint
#
# Indexes
#
# index_captain_assistant_responses_on_account_id (account_id)
# index_captain_assistant_responses_on_assistant_id (assistant_id)
# index_captain_assistant_responses_on_document_id (document_id)
# vector_idx_knowledge_entries_embedding (embedding) USING ivfflat
#
class Captain::AssistantResponse < ApplicationRecord
self.table_name = 'captain_assistant_responses'
belongs_to :assistant, class_name: 'Captain::Assistant'
belongs_to :account
belongs_to :document, optional: true, class_name: 'Captain::Document'
has_neighbors :embedding, normalize: true
validates :question, presence: true
validates :answer, presence: true
before_validation :ensure_account
after_commit :update_response_embedding
scope :ordered, -> { order(created_at: :desc) }
scope :by_account, ->(account_id) { where(account_id: account_id) }
scope :by_assistant, ->(assistant_id) { where(assistant_id: assistant_id) }
scope :with_document, ->(document_id) { where(document_id: document_id) }
def self.search(query)
embedding = Captain::Llm::EmbeddingService.new.get_embedding(query)
nearest_neighbors(:embedding, embedding, distance: 'cosine').limit(5)
end
private
def ensure_account
self.account = assistant&.account
end
def update_response_embedding
return unless saved_change_to_question? || saved_change_to_answer? || embedding.nil?
Captain::Llm::UpdateEmbeddingJob.perform_later(self, "#{question}: #{answer}")
end
end

View File

@@ -0,0 +1,62 @@
# == Schema Information
#
# Table name: captain_documents
#
# id :bigint not null, primary key
# content :text
# external_link :string not null
# name :string
# status :integer default("in_progress"), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# assistant_id :bigint not null
#
# Indexes
#
# index_captain_documents_on_account_id (account_id)
# index_captain_documents_on_assistant_id (assistant_id)
# index_captain_documents_on_assistant_id_and_external_link (assistant_id,external_link) UNIQUE
# index_captain_documents_on_status (status)
#
class Captain::Document < ApplicationRecord
self.table_name = 'captain_documents'
belongs_to :assistant, class_name: 'Captain::Assistant'
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy
belongs_to :account
validates :external_link, presence: true
validates :external_link, uniqueness: { scope: :assistant_id }
before_validation :ensure_account_id
enum status: {
in_progress: 0,
available: 1
}
after_create_commit :enqueue_crawl_job
after_commit :enqueue_response_builder_job
scope :ordered, -> { order(created_at: :desc) }
scope :for_account, ->(account_id) { where(account_id: account_id) }
scope :for_assistant, ->(assistant_id) { where(assistant_id: assistant_id) }
private
def enqueue_crawl_job
return if status != 'in_progress'
Captain::Documents::CrawlJob.perform_later(self)
end
def enqueue_response_builder_job
return if status != 'available'
Captain::Documents::ResponseBuilderJob.perform_later(self)
end
def ensure_account_id
self.account_id = assistant&.account_id
end
end

View File

@@ -0,0 +1,22 @@
# == Schema Information
#
# Table name: captain_inboxes
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# captain_assistant_id :bigint not null
# inbox_id :bigint not null
#
# Indexes
#
# index_captain_inboxes_on_captain_assistant_id (captain_assistant_id)
# index_captain_inboxes_on_captain_assistant_id_and_inbox_id (captain_assistant_id,inbox_id) UNIQUE
# index_captain_inboxes_on_inbox_id (inbox_id)
#
class CaptainInbox < ApplicationRecord
belongs_to :captain_assistant, class_name: 'Captain::Assistant'
belongs_to :inbox
validates :inbox_id, uniqueness: true
end

View File

@@ -6,12 +6,8 @@ module Enterprise::Concerns::Account
has_many :applied_slas, dependent: :destroy_async
has_many :custom_roles, dependent: :destroy_async
def self.add_response_related_associations
has_many :response_sources, dependent: :destroy_async
has_many :response_documents, dependent: :destroy_async
has_many :responses, dependent: :destroy_async
end
add_response_related_associations if Features::ResponseBotService.new.vector_extension_enabled?
has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant'
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
end
end

View File

@@ -8,10 +8,10 @@ module Enterprise::Concerns::Article
has_many :article_embeddings, dependent: :destroy_async
end
add_article_embedding_association if Features::HelpcenterEmbeddingSearchService.new.feature_enabled?
add_article_embedding_association
def self.vector_search(params)
embedding = Openai::EmbeddingsService.new.get_embedding(params['query'], 'text-embedding-3-small')
embedding = Captain::Llm::EmbeddingService.new.get_embedding(params['query'])
records = joins(
:category
).search_by_category_slug(

View File

@@ -2,13 +2,9 @@ module Enterprise::Concerns::Inbox
extend ActiveSupport::Concern
included do
def self.add_response_related_associations
has_many :inbox_response_sources, dependent: :destroy_async
has_many :response_sources, through: :inbox_response_sources
has_many :response_documents, through: :response_sources
has_many :responses, through: :response_sources
end
add_response_related_associations if Features::ResponseBotService.new.vector_extension_enabled?
has_one :captain_inbox, dependent: :destroy, class_name: 'CaptainInbox'
has_one :captain_assistant,
through: :captain_inbox,
class_name: 'Captain::Assistant'
end
end

View File

@@ -5,17 +5,8 @@ module Enterprise::Inbox
super - overloaded_agent_ids
end
def get_responses(query)
embedding = Openai::EmbeddingsService.new.get_embedding(query)
responses.active.nearest_neighbors(:embedding, embedding, distance: 'cosine').first(5)
end
def active_bot?
super || response_bot_enabled?
end
def response_bot_enabled?
account.feature_enabled?('response_bot') && response_sources.any?
super || captain_assistant.present?
end
private

View File

@@ -1,21 +0,0 @@
# == Schema Information
#
# Table name: inbox_response_sources
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# inbox_id :bigint not null
# response_source_id :bigint not null
#
# Indexes
#
# index_inbox_response_sources_on_inbox_id (inbox_id)
# index_inbox_response_sources_on_inbox_id_and_response_source_id (inbox_id,response_source_id) UNIQUE
# index_inbox_response_sources_on_response_source_id (response_source_id)
# index_inbox_response_sources_on_response_source_id_and_inbox_id (response_source_id,inbox_id) UNIQUE
#
class InboxResponseSource < ApplicationRecord
belongs_to :inbox
belongs_to :response_source
end

View File

@@ -1,46 +0,0 @@
# == Schema Information
#
# Table name: responses
#
# id :bigint not null, primary key
# answer :text not null
# embedding :vector(1536)
# question :string not null
# status :integer default("pending")
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# response_document_id :bigint
# response_source_id :bigint not null
#
# Indexes
#
# index_responses_on_embedding (embedding) USING ivfflat
# index_responses_on_response_document_id (response_document_id)
#
class Response < ApplicationRecord
belongs_to :response_document, optional: true
belongs_to :account
belongs_to :response_source
has_neighbors :embedding, normalize: true
before_save :update_response_embedding
before_validation :ensure_account
enum status: { pending: 0, active: 1 }
def self.search(query)
embedding = Openai::EmbeddingsService.new.get_embedding(query)
nearest_neighbors(:embedding, embedding, distance: 'cosine').first(5)
end
private
def ensure_account
self.account = response_source.account
end
def update_response_embedding
self.embedding = Openai::EmbeddingsService.new.get_embedding("#{question}: #{answer}")
end
end

View File

@@ -1,46 +0,0 @@
# == Schema Information
#
# Table name: response_documents
#
# id :bigint not null, primary key
# content :text
# document_link :string
# document_type :string
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# document_id :bigint
# response_source_id :bigint not null
#
# Indexes
#
# index_response_documents_on_document (document_type,document_id)
# index_response_documents_on_response_source_id (response_source_id)
#
class ResponseDocument < ApplicationRecord
has_many :responses, dependent: :destroy_async
belongs_to :account
belongs_to :response_source
before_validation :set_account
after_create :ensure_content
after_update :handle_content_change
private
def set_account
self.account = response_source.account
end
def ensure_content
return unless content.nil?
ResponseBot::ResponseDocumentContentJob.perform_later(self)
end
def handle_content_change
return unless saved_change_to_content? && content.present?
ResponseBot::ResponseBuilderJob.perform_later(self)
end
end

View File

@@ -1,33 +0,0 @@
# == Schema Information
#
# Table name: response_sources
#
# id :bigint not null, primary key
# name :string not null
# source_link :string
# source_model_type :string
# source_type :integer default("external"), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# source_model_id :bigint
#
# Indexes
#
# index_response_sources_on_source_model (source_model_type,source_model_id)
#
class ResponseSource < ApplicationRecord
enum source_type: { external: 0, kbase: 1, inbox: 2 }
has_many :inbox_response_sources, dependent: :destroy_async
has_many :inboxes, through: :inbox_response_sources
belongs_to :account
has_many :response_documents, dependent: :destroy_async
has_many :responses, dependent: :destroy_async
accepts_nested_attributes_for :response_documents
def get_responses(query)
embedding = Openai::EmbeddingsService.new.get_embedding(query)
responses.active.nearest_neighbors(:embedding, embedding, distance: 'cosine').first(5)
end
end