Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
121
research/chatwoot/enterprise/app/models/captain/assistant.rb
Normal file
121
research/chatwoot/enterprise/app/models/captain/assistant.rb
Normal file
@@ -0,0 +1,121 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: captain_assistants
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# config :jsonb not null
|
||||
# description :string
|
||||
# guardrails :jsonb
|
||||
# name :string not null
|
||||
# response_guidelines :jsonb
|
||||
# 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
|
||||
include Avatarable
|
||||
include Concerns::CaptainToolsHelpers
|
||||
include Concerns::Agentable
|
||||
|
||||
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
|
||||
has_many :messages, as: :sender, dependent: :nullify
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
|
||||
|
||||
store_accessor :config, :temperature, :feature_faq, :feature_memory, :product_name
|
||||
|
||||
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) }
|
||||
|
||||
def available_name
|
||||
name
|
||||
end
|
||||
|
||||
def available_agent_tools
|
||||
tools = self.class.built_in_agent_tools.dup
|
||||
|
||||
custom_tools = account.captain_custom_tools.enabled.map(&:to_tool_metadata)
|
||||
tools.concat(custom_tools)
|
||||
|
||||
tools
|
||||
end
|
||||
|
||||
def available_tool_ids
|
||||
available_agent_tools.pluck(:id)
|
||||
end
|
||||
|
||||
def push_event_data
|
||||
{
|
||||
id: id,
|
||||
name: name,
|
||||
avatar_url: avatar_url.presence || default_avatar_url,
|
||||
description: description,
|
||||
created_at: created_at,
|
||||
type: 'captain_assistant'
|
||||
}
|
||||
end
|
||||
|
||||
def webhook_data
|
||||
{
|
||||
id: id,
|
||||
name: name,
|
||||
avatar_url: avatar_url.presence || default_avatar_url,
|
||||
description: description,
|
||||
created_at: created_at,
|
||||
type: 'captain_assistant'
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def agent_name
|
||||
name.parameterize(separator: '_')
|
||||
end
|
||||
|
||||
def agent_tools
|
||||
[
|
||||
self.class.resolve_tool_class('faq_lookup').new(self),
|
||||
self.class.resolve_tool_class('handoff').new(self)
|
||||
]
|
||||
end
|
||||
|
||||
def prompt_context
|
||||
{
|
||||
name: name,
|
||||
description: description,
|
||||
product_name: config['product_name'] || 'this product',
|
||||
scenarios: scenarios.enabled.map do |scenario|
|
||||
{
|
||||
title: scenario.title,
|
||||
key: scenario.title.parameterize.underscore,
|
||||
description: scenario.description
|
||||
}
|
||||
end,
|
||||
response_guidelines: response_guidelines || [],
|
||||
guardrails: guardrails || []
|
||||
}
|
||||
end
|
||||
|
||||
def default_avatar_url
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/assets/images/dashboard/captain/logo.svg"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,67 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: captain_assistant_responses
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# answer :text not null
|
||||
# documentable_type :string
|
||||
# embedding :vector(1536)
|
||||
# question :string not null
|
||||
# status :integer default("approved"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# assistant_id :bigint not null
|
||||
# documentable_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_cap_asst_resp_on_documentable (documentable_id,documentable_type)
|
||||
# index_captain_assistant_responses_on_account_id (account_id)
|
||||
# index_captain_assistant_responses_on_assistant_id (assistant_id)
|
||||
# index_captain_assistant_responses_on_status (status)
|
||||
# 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 :documentable, polymorphic: true, optional: true
|
||||
has_neighbors :embedding, normalize: true
|
||||
|
||||
validates :question, presence: true
|
||||
validates :answer, presence: true
|
||||
|
||||
before_validation :ensure_account
|
||||
before_validation :ensure_status
|
||||
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) }
|
||||
|
||||
enum status: { pending: 0, approved: 1 }
|
||||
|
||||
def self.search(query, account_id: nil)
|
||||
embedding = Captain::Llm::EmbeddingService.new(account_id: account_id).get_embedding(query)
|
||||
nearest_neighbors(:embedding, embedding, distance: 'cosine').limit(5)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_status
|
||||
self.status ||= :approved
|
||||
end
|
||||
|
||||
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
|
||||
100
research/chatwoot/enterprise/app/models/captain/custom_tool.rb
Normal file
100
research/chatwoot/enterprise/app/models/captain/custom_tool.rb
Normal file
@@ -0,0 +1,100 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: captain_custom_tools
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# auth_config :jsonb
|
||||
# auth_type :string default("none")
|
||||
# description :text
|
||||
# enabled :boolean default(TRUE), not null
|
||||
# endpoint_url :text not null
|
||||
# http_method :string default("GET"), not null
|
||||
# param_schema :jsonb
|
||||
# request_template :text
|
||||
# response_template :text
|
||||
# slug :string not null
|
||||
# title :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_captain_custom_tools_on_account_id (account_id)
|
||||
# index_captain_custom_tools_on_account_id_and_slug (account_id,slug) UNIQUE
|
||||
#
|
||||
class Captain::CustomTool < ApplicationRecord
|
||||
include Concerns::Toolable
|
||||
include Concerns::SafeEndpointValidatable
|
||||
|
||||
self.table_name = 'captain_custom_tools'
|
||||
|
||||
NAME_PREFIX = 'custom'.freeze
|
||||
NAME_SEPARATOR = '_'.freeze
|
||||
PARAM_SCHEMA_VALIDATION = {
|
||||
'type': 'array',
|
||||
'items': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'name': { 'type': 'string' },
|
||||
'type': { 'type': 'string' },
|
||||
'description': { 'type': 'string' },
|
||||
'required': { 'type': 'boolean' }
|
||||
},
|
||||
'required': %w[name type description],
|
||||
'additionalProperties': false
|
||||
}
|
||||
}.to_json.freeze
|
||||
|
||||
belongs_to :account
|
||||
|
||||
enum :http_method, %w[GET POST].index_by(&:itself), validate: true
|
||||
enum :auth_type, %w[none bearer basic api_key].index_by(&:itself), default: :none, validate: true, prefix: :auth
|
||||
|
||||
before_validation :generate_slug
|
||||
|
||||
validates :slug, presence: true, uniqueness: { scope: :account_id }
|
||||
validates :title, presence: true
|
||||
validates :endpoint_url, presence: true
|
||||
validates_with JsonSchemaValidator,
|
||||
schema: PARAM_SCHEMA_VALIDATION,
|
||||
attribute_resolver: ->(record) { record.param_schema }
|
||||
|
||||
scope :enabled, -> { where(enabled: true) }
|
||||
|
||||
def to_tool_metadata
|
||||
{
|
||||
id: slug,
|
||||
title: title,
|
||||
description: description,
|
||||
custom: true
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_slug
|
||||
return if slug.present?
|
||||
return if title.blank?
|
||||
|
||||
paramterized_title = title.parameterize(separator: NAME_SEPARATOR)
|
||||
|
||||
base_slug = "#{NAME_PREFIX}#{NAME_SEPARATOR}#{paramterized_title}"
|
||||
self.slug = find_unique_slug(base_slug)
|
||||
end
|
||||
|
||||
def find_unique_slug(base_slug)
|
||||
return base_slug unless slug_exists?(base_slug)
|
||||
|
||||
5.times do
|
||||
slug_candidate = "#{base_slug}#{NAME_SEPARATOR}#{SecureRandom.alphanumeric(6).downcase}"
|
||||
return slug_candidate unless slug_exists?(slug_candidate)
|
||||
end
|
||||
|
||||
raise ActiveRecord::RecordNotUnique, I18n.t('captain.custom_tool.slug_generation_failed')
|
||||
end
|
||||
|
||||
def slug_exists?(candidate)
|
||||
self.class.exists?(account_id: account_id, slug: candidate)
|
||||
end
|
||||
end
|
||||
154
research/chatwoot/enterprise/app/models/captain/document.rb
Normal file
154
research/chatwoot/enterprise/app/models/captain/document.rb
Normal file
@@ -0,0 +1,154 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: captain_documents
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# content :text
|
||||
# external_link :string not null
|
||||
# metadata :jsonb
|
||||
# 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
|
||||
class LimitExceededError < StandardError; end
|
||||
self.table_name = 'captain_documents'
|
||||
|
||||
belongs_to :assistant, class_name: 'Captain::Assistant'
|
||||
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy, as: :documentable
|
||||
belongs_to :account
|
||||
has_one_attached :pdf_file
|
||||
|
||||
validates :external_link, presence: true, unless: -> { pdf_file.attached? }
|
||||
validates :external_link, uniqueness: { scope: :assistant_id }, allow_blank: true
|
||||
validates :content, length: { maximum: 200_000 }
|
||||
validates :pdf_file, presence: true, if: :pdf_document?
|
||||
validate :validate_pdf_format, if: :pdf_document?
|
||||
validate :validate_file_attachment, if: -> { pdf_file.attached? }
|
||||
before_validation :ensure_account_id
|
||||
before_validation :set_external_link_for_pdf
|
||||
before_validation :normalize_external_link
|
||||
|
||||
enum status: {
|
||||
in_progress: 0,
|
||||
available: 1
|
||||
}
|
||||
|
||||
before_create :ensure_within_plan_limit
|
||||
after_create_commit :enqueue_crawl_job
|
||||
after_create_commit :update_document_usage
|
||||
after_destroy :update_document_usage
|
||||
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) }
|
||||
|
||||
def pdf_document?
|
||||
return true if pdf_file.attached? && pdf_file.blob.content_type == 'application/pdf'
|
||||
|
||||
external_link&.ends_with?('.pdf')
|
||||
end
|
||||
|
||||
def content_type
|
||||
pdf_file.blob.content_type if pdf_file.attached?
|
||||
end
|
||||
|
||||
def file_size
|
||||
pdf_file.blob.byte_size if pdf_file.attached?
|
||||
end
|
||||
|
||||
def openai_file_id
|
||||
metadata&.dig('openai_file_id')
|
||||
end
|
||||
|
||||
def store_openai_file_id(file_id)
|
||||
update!(metadata: (metadata || {}).merge('openai_file_id' => file_id))
|
||||
end
|
||||
|
||||
def display_url
|
||||
return external_link if external_link.present? && !external_link.start_with?('PDF:')
|
||||
|
||||
if pdf_file.attached?
|
||||
Rails.application.routes.url_helpers.rails_blob_url(pdf_file, only_path: false)
|
||||
else
|
||||
external_link
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enqueue_crawl_job
|
||||
return if status != 'in_progress'
|
||||
|
||||
Captain::Documents::CrawlJob.perform_later(self)
|
||||
end
|
||||
|
||||
def enqueue_response_builder_job
|
||||
return unless should_enqueue_response_builder?
|
||||
|
||||
Captain::Documents::ResponseBuilderJob.perform_later(self)
|
||||
end
|
||||
|
||||
def should_enqueue_response_builder?
|
||||
return false if destroyed?
|
||||
return false unless available?
|
||||
|
||||
return saved_change_to_status? if pdf_document?
|
||||
|
||||
(saved_change_to_status? || saved_change_to_content?) && content.present?
|
||||
end
|
||||
|
||||
def update_document_usage
|
||||
account.update_document_usage
|
||||
end
|
||||
|
||||
def ensure_account_id
|
||||
self.account_id = assistant&.account_id
|
||||
end
|
||||
|
||||
def ensure_within_plan_limit
|
||||
limits = account.usage_limits[:captain][:documents]
|
||||
raise LimitExceededError, I18n.t('captain.documents.limit_exceeded') unless limits[:current_available].positive?
|
||||
end
|
||||
|
||||
def validate_pdf_format
|
||||
return unless pdf_file.attached?
|
||||
|
||||
errors.add(:pdf_file, I18n.t('captain.documents.pdf_format_error')) unless pdf_file.blob.content_type == 'application/pdf'
|
||||
end
|
||||
|
||||
def validate_file_attachment
|
||||
return unless pdf_file.attached?
|
||||
|
||||
return unless pdf_file.blob.byte_size > 10.megabytes
|
||||
|
||||
errors.add(:pdf_file, I18n.t('captain.documents.pdf_size_error'))
|
||||
end
|
||||
|
||||
def set_external_link_for_pdf
|
||||
return unless pdf_file.attached? && external_link.blank?
|
||||
|
||||
# Set a unique external_link for PDF files
|
||||
# Format: PDF: filename_timestamp (without extension)
|
||||
timestamp = Time.current.strftime('%Y%m%d%H%M%S')
|
||||
self.external_link = "PDF: #{pdf_file.filename.base}_#{timestamp}"
|
||||
end
|
||||
|
||||
def normalize_external_link
|
||||
return if external_link.blank?
|
||||
return if pdf_document?
|
||||
|
||||
self.external_link = external_link.delete_suffix('/')
|
||||
end
|
||||
end
|
||||
139
research/chatwoot/enterprise/app/models/captain/scenario.rb
Normal file
139
research/chatwoot/enterprise/app/models/captain/scenario.rb
Normal file
@@ -0,0 +1,139 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: captain_scenarios
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# description :text
|
||||
# enabled :boolean default(TRUE), not null
|
||||
# instruction :text
|
||||
# title :string
|
||||
# tools :jsonb
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# assistant_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_captain_scenarios_on_account_id (account_id)
|
||||
# index_captain_scenarios_on_assistant_id (assistant_id)
|
||||
# index_captain_scenarios_on_assistant_id_and_enabled (assistant_id,enabled)
|
||||
# index_captain_scenarios_on_enabled (enabled)
|
||||
#
|
||||
class Captain::Scenario < ApplicationRecord
|
||||
include Concerns::CaptainToolsHelpers
|
||||
include Concerns::Agentable
|
||||
|
||||
self.table_name = 'captain_scenarios'
|
||||
|
||||
belongs_to :assistant, class_name: 'Captain::Assistant'
|
||||
belongs_to :account
|
||||
|
||||
validates :title, presence: true
|
||||
validates :description, presence: true
|
||||
validates :instruction, presence: true
|
||||
validates :assistant_id, presence: true
|
||||
validates :account_id, presence: true
|
||||
validate :validate_instruction_tools
|
||||
|
||||
scope :enabled, -> { where(enabled: true) }
|
||||
|
||||
delegate :temperature, :feature_faq, :feature_memory, :product_name, :response_guidelines, :guardrails, to: :assistant
|
||||
|
||||
before_save :resolve_tool_references
|
||||
|
||||
def prompt_context
|
||||
{
|
||||
title: title,
|
||||
instructions: resolved_instructions,
|
||||
tools: resolved_tools,
|
||||
assistant_name: assistant.name.downcase.gsub(/\s+/, '_'),
|
||||
response_guidelines: response_guidelines || [],
|
||||
guardrails: guardrails || []
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def agent_name
|
||||
"#{title} Agent".parameterize(separator: '_')
|
||||
end
|
||||
|
||||
def agent_tools
|
||||
resolved_tools.map { |tool| resolve_tool_instance(tool) }
|
||||
end
|
||||
|
||||
def resolved_instructions
|
||||
instruction.gsub(TOOL_REFERENCE_REGEX, '`\1` tool')
|
||||
end
|
||||
|
||||
def resolved_tools
|
||||
return [] if tools.blank?
|
||||
|
||||
available_tools = assistant.available_agent_tools
|
||||
tools.filter_map do |tool_id|
|
||||
available_tools.find { |tool| tool[:id] == tool_id }
|
||||
end
|
||||
end
|
||||
|
||||
def resolve_tool_instance(tool_metadata)
|
||||
tool_id = tool_metadata[:id]
|
||||
|
||||
if tool_metadata[:custom]
|
||||
custom_tool = Captain::CustomTool.find_by(slug: tool_id, account_id: account_id, enabled: true)
|
||||
custom_tool&.tool(assistant)
|
||||
else
|
||||
tool_class = self.class.resolve_tool_class(tool_id)
|
||||
tool_class&.new(assistant)
|
||||
end
|
||||
end
|
||||
|
||||
# Validates that all tool references in the instruction are valid.
|
||||
# Parses the instruction for tool references and checks if they exist
|
||||
# in the available tools configuration.
|
||||
#
|
||||
# @return [void]
|
||||
# @api private
|
||||
# @example Valid instruction
|
||||
# scenario.instruction = "Use [Add Contact Note](tool://add_contact_note) to document"
|
||||
# scenario.valid? # => true
|
||||
#
|
||||
# @example Invalid instruction
|
||||
# scenario.instruction = "Use [Invalid Tool](tool://invalid_tool) to process"
|
||||
# scenario.valid? # => false
|
||||
# scenario.errors[:instruction] # => ["contains invalid tools: invalid_tool"]
|
||||
def validate_instruction_tools
|
||||
return if instruction.blank?
|
||||
|
||||
tool_ids = extract_tool_ids_from_text(instruction)
|
||||
return if tool_ids.empty?
|
||||
|
||||
all_available_tool_ids = assistant.available_tool_ids
|
||||
invalid_tools = tool_ids - all_available_tool_ids
|
||||
|
||||
return unless invalid_tools.any?
|
||||
|
||||
errors.add(:instruction, "contains invalid tools: #{invalid_tools.join(', ')}")
|
||||
end
|
||||
|
||||
# Resolves tool references from the instruction text into the tools field.
|
||||
# Parses the instruction for tool references and materializes them as
|
||||
# tool IDs stored in the tools JSONB field.
|
||||
#
|
||||
# @return [void]
|
||||
# @api private
|
||||
# @example
|
||||
# scenario.instruction = "First [@Add Private Note](tool://add_private_note) then [@Update Priority](tool://update_priority)"
|
||||
# scenario.save!
|
||||
# scenario.tools # => ["add_private_note", "update_priority"]
|
||||
#
|
||||
# scenario.instruction = "No tools mentioned here"
|
||||
# scenario.save!
|
||||
# scenario.tools # => nil
|
||||
def resolve_tool_references
|
||||
return if instruction.blank?
|
||||
|
||||
tool_ids = extract_tool_ids_from_text(instruction)
|
||||
self.tools = tool_ids.presence
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user