Compare commits

...
Author SHA1 Message Date
Shivam Mishra 6a54124f38 fix: message response 2025-04-04 22:56:28 +05:30
Shivam Mishra 94b81b49f9 feat: scope assistant 2025-04-04 22:49:03 +05:30
Shivam Mishra 45afde970b feat: refactor assistant chat service to use RubyLLM 2025-04-04 14:53:04 +05:30
Shivam Mishra 3e52518f73 feat: update ruby_llm gem 2025-04-04 14:50:41 +05:30
Shivam Mishra f22c5859a0 feat: add RubyLLM based doc search tool 2025-04-04 14:34:05 +05:30
Shivam Mishra d13437dc88 feat: add base llm service using rubyllm 2025-04-04 14:16:48 +05:30
Shivam Mishra 74db596b22 fix: gemfile 2025-04-04 14:14:14 +05:30
Shivam Mishra 0d0df0eead feat: add ruby llm gem 2025-04-04 14:08:40 +05:30
Shivam Mishra 368853c46f test: update specs 2025-04-04 13:46:53 +05:30
Shivam Mishra 62ff1bd883 test: add specs for chat helper 2025-04-04 13:33:36 +05:30
Shivam Mishra 4938e9847e test: add specs for embedding service 2025-04-04 13:22:14 +05:30
Shivam Mishra 46c69cb1b0 test: add test for FAQ generation 2025-04-04 13:22:01 +05:30
Shivam Mishra 933fcb7d1f test: add specs for contact notes service 2025-04-04 13:18:22 +05:30
Shivam Mishra 731d616e7c test: add specs for assistant chat service 2025-04-04 13:13:57 +05:30
Sojan JoseandGitHub 1a78a9243f chore: Clean up report & knowledge base policies (#11234)
- Removes the portal_members table and all associated records
- Updates policies to use custom roles with knowledge_base_manage
permission
- Updates controllers, models, and views to work without portal
membership
- Adds tests for the new permission model
2025-04-03 16:00:32 -07:00
48 changed files with 1438 additions and 248 deletions
+1
View File
@@ -174,6 +174,7 @@ gem 'pgvector'
gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby_llm', '1.1.0rc1'
gem 'ruby-openai'
gem 'shopify_api'
+8
View File
@@ -698,6 +698,13 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_llm (1.1.0rc1)
base64
event_stream_parser (~> 1)
faraday (~> 2)
faraday-multipart (~> 1)
faraday-retry (~> 2)
zeitwerk (~> 2)
ruby_parser (3.20.0)
sexp_processor (~> 4.16)
sass (3.7.4)
@@ -964,6 +971,7 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
ruby-openai
ruby_llm (= 1.1.0rc1)
scout_apm
scss_lint
seed_dump
@@ -9,11 +9,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
@portals = Current.account.portals
end
def add_members
agents = Current.account.agents.where(id: portal_member_params[:member_ids])
@portal.members << agents
end
def show
@all_articles = @portal.articles
@articles = @all_articles.search(locale: params[:locale])
@@ -85,10 +80,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
{ channel_web_widget_id: inbox.channel.id }
end
def portal_member_params
params.require(:portal).permit(:account_id, member_ids: [])
end
def set_current_page
@current_page = params[:page] || 1
end
@@ -66,9 +66,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
end
def check_authorization
return if Current.account_user.administrator?
raise Pundit::NotAuthorizedError
authorize :report, :view?
end
def common_params
@@ -137,5 +135,3 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end
end
Api::V2::Accounts::ReportsController.prepend_mod_with('Api::V2::Accounts::ReportsController')
+13 -14
View File
@@ -2,20 +2,19 @@
#
# Table name: accounts
#
# id :integer not null, primary key
# auto_resolve_duration :integer
# contactable_contacts_count :integer default(0)
# custom_attributes :jsonb
# domain :string(100)
# feature_flags :bigint default(0), not null
# internal_attributes :jsonb not null
# limits :jsonb
# locale :integer default("en")
# name :string not null
# status :integer default("active")
# support_email :string(100)
# created_at :datetime not null
# updated_at :datetime not null
# id :integer not null, primary key
# auto_resolve_duration :integer
# custom_attributes :jsonb
# domain :string(100)
# feature_flags :bigint default(0), not null
# internal_attributes :jsonb not null
# limits :jsonb
# locale :integer default("en")
# name :string not null
# status :integer default("active")
# support_email :string(100)
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
-10
View File
@@ -30,14 +30,6 @@ class Portal < ApplicationRecord
has_many :categories, dependent: :destroy_async
has_many :folders, through: :categories
has_many :articles, dependent: :destroy_async
has_many :portal_members,
class_name: :PortalMember,
dependent: :destroy_async
has_many :members,
through: :portal_members,
class_name: :User,
dependent: :nullify,
source: :user
has_one_attached :logo
has_many :inboxes, dependent: :nullify
belongs_to :channel_web_widget, class_name: 'Channel::WebWidget', optional: true
@@ -49,8 +41,6 @@ class Portal < ApplicationRecord
validates :custom_domain, uniqueness: true, allow_nil: true
validate :config_json_format
accepts_nested_attributes_for :members
scope :active, -> { where(archived: false) }
CONFIG_JSON_KEYS = %w[allowed_locales default_locale website_token].freeze
-20
View File
@@ -1,20 +0,0 @@
# == Schema Information
#
# Table name: portal_members
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# portal_id :bigint
# user_id :bigint
#
# Indexes
#
# index_portal_members_on_portal_id_and_user_id (portal_id,user_id) UNIQUE
# index_portal_members_on_user_id_and_portal_id (user_id,portal_id) UNIQUE
#
class PortalMember < ApplicationRecord
belongs_to :portal, class_name: 'Portal'
belongs_to :user, class_name: 'User'
validates :user_id, uniqueness: { scope: :portal_id }
end
-4
View File
@@ -95,10 +95,6 @@ class User < ApplicationRecord
has_many :team_members, dependent: :destroy_async
has_many :teams, through: :team_members
has_many :articles, foreign_key: 'author_id', dependent: :nullify, inverse_of: :author
has_many :portal_members, class_name: :PortalMember, dependent: :destroy_async
has_many :portals, through: :portal_members, source: :portal,
class_name: :Portal,
dependent: :nullify
# rubocop:disable Rails/HasManyOrHasOneDependent
# we are handling this in `remove_macros` callback
has_many :macros, foreign_key: 'created_by_id', inverse_of: :created_by
+8 -14
View File
@@ -1,37 +1,31 @@
class ArticlePolicy < ApplicationPolicy
def index?
@account_user.administrator? || @account.users.include?(@user)
@account.users.include?(@user)
end
def update?
@account_user.administrator? || portal_member?
@account_user.administrator?
end
def show?
@account_user.administrator? || portal_member?
@account_user.administrator?
end
def edit?
@account_user.administrator? || portal_member?
@account_user.administrator?
end
def create?
@account_user.administrator? || portal_member?
@account_user.administrator?
end
def destroy?
@account_user.administrator? || portal_member?
@account_user.administrator?
end
def reorder?
@account_user.administrator? || portal_member?
end
private
def portal_member?
@record.first.portal.members.include?(@user)
@account_user.administrator?
end
end
ArticlePolicy.prepend_mod_with('Enterprise::ArticlePolicy')
ArticlePolicy.prepend_mod_with('ArticlePolicy')
+7 -13
View File
@@ -1,33 +1,27 @@
class CategoryPolicy < ApplicationPolicy
def index?
@account_user.administrator? || @account.users.include?(@user)
@account.users.include?(@user)
end
def update?
@account_user.administrator? || portal_member?
@account_user.administrator?
end
def show?
@account_user.administrator? || portal_member?
@account_user.administrator?
end
def edit?
@account_user.administrator? || portal_member?
@account_user.administrator?
end
def create?
@account_user.administrator? || portal_member?
@account_user.administrator?
end
def destroy?
@account_user.administrator? || portal_member?
end
private
def portal_member?
@record.first.portal.members.include?(@user)
@account_user.administrator?
end
end
CategoryPolicy.prepend_mod_with('Enterprise::CategoryPolicy')
CategoryPolicy.prepend_mod_with('CategoryPolicy')
+3 -13
View File
@@ -1,6 +1,6 @@
class PortalPolicy < ApplicationPolicy
def index?
@account_user.administrator? || @account.users.include?(@user)
@account.users.include?(@user)
end
def update?
@@ -8,7 +8,7 @@ class PortalPolicy < ApplicationPolicy
end
def show?
@account_user.administrator? || portal_member?
@account.users.include?(@user)
end
def edit?
@@ -23,19 +23,9 @@ class PortalPolicy < ApplicationPolicy
@account_user.administrator?
end
def add_members?
@account_user.administrator?
end
def logo?
@account_user.administrator?
end
private
def portal_member?
@record.first.members.include?(@user)
end
end
PortalPolicy.prepend_mod_with('Enterprise::PortalPolicy')
PortalPolicy.prepend_mod_with('PortalPolicy')
+1 -1
View File
@@ -4,4 +4,4 @@ class ReportPolicy < ApplicationPolicy
end
end
ReportPolicy.prepend_mod_with('Enterprise::ReportPolicy')
ReportPolicy.prepend_mod_with('ReportPolicy')
@@ -25,14 +25,6 @@ end
json.logo portal.file_base_data if portal.logo.present?
json.portal_members do
if portal.members.any?
json.array! portal.members.each do |member|
json.partial! 'api/v1/models/agent', formats: [:json], resource: member
end
end
end
json.meta do
json.all_articles_count articles.try(:size)
json.archived_articles_count articles.try(:archived).try(:size)
-1
View File
@@ -264,7 +264,6 @@ Rails.application.routes.draw do
resources :portals do
member do
patch :archive
put :add_members
delete :logo
end
resources :categories
@@ -0,0 +1,16 @@
class RemovePortalMembers < ActiveRecord::Migration[7.0]
def up
drop_table :portal_members
end
def down
create_table :portal_members do |t|
t.references :portal, index: false
t.references :user, index: false
t.timestamps
end
add_index :portal_members, [:portal_id, :user_id], unique: true
add_index :portal_members, [:user_id, :portal_id], unique: true
end
end
+1 -10
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2025_03_26_034635) do
ActiveRecord::Schema[7.0].define(version: 2025_04_02_233933) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -871,15 +871,6 @@ ActiveRecord::Schema[7.0].define(version: 2025_03_26_034635) do
t.datetime "updated_at", null: false
end
create_table "portal_members", force: :cascade do |t|
t.bigint "portal_id"
t.bigint "user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["portal_id", "user_id"], name: "index_portal_members_on_portal_id_and_user_id", unique: true
t.index ["user_id", "portal_id"], name: "index_portal_members_on_user_id_and_portal_id", unique: true
end
create_table "portals", force: :cascade do |t|
t.integer "account_id", null: false
t.string "name", null: false
@@ -1,7 +0,0 @@
module Enterprise::Api::V2::Accounts::ReportsController
def check_authorization
return if Current.account_user.custom_role&.permissions&.include?('report_manage')
super
end
end
@@ -1,32 +1,12 @@
module Enterprise::PortalPolicy
def index?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def update?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def show?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def edit?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def create?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def destroy?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def add_members?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def logo?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
@@ -1,28 +1,37 @@
require 'openai'
class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
include Captain::ChatHelper
class Captain::Llm::AssistantChatService < Llm::BaseService
def initialize(assistant: nil)
super()
@assistant = assistant
@messages = [system_message]
@response = ''
search_tool = Captain::Tools::DocumentationSearch.new(assistant)
@chat = ::RubyLLM.chat(model: @model).with_tool(search_tool)
@chat.with_instructions(system_message)
end
def generate_response(input, previous_messages = [], role = 'user')
@messages += previous_messages
@messages << { role: role, content: input } if input.present?
request_chat_completion
def generate_response(input, previous_messages = [], _role = 'user')
previous_messages.each do |msg|
@chat.add_message(role: msg[:role], content: msg[:content])
end
response = @chat.ask(input) if input.present?
format_response(response)
end
private
def system_message
{
role: 'system',
content: Captain::Llm::SystemPromptsService.assistant_response_generator(@assistant.config['product_name'])
}
Captain::Llm::SystemPromptsService.assistant_response_generator(@assistant.config['product_name'])
end
def format_response(response)
return '' if response.nil?
content = response.content
return 'conversation_handoff' if content.include?('conversation_handoff')
begin
::JSON.parse(content)
rescue ::JSON::ParserError
content
end
end
end
@@ -0,0 +1,38 @@
class Captain::Tools::DocumentationSearch < RubyLLM::Tool
description 'Search through the documentation to find relevant answers'
param :search_query,
type: :string,
desc: 'The search query to find relevant documentation'
def initialize(assistant)
super()
@assistant = assistant
end
def execute(query)
@assistant
.responses
.approved
.search(query)
.map { |response| format_response(response) }.join
format_responses(responses)
end
private
def format_response(response)
formatted_response = "
Question: #{response.question}
Answer: #{response.answer}
"
if response.documentable.present? && response.documentable.try(:external_link)
formatted_response += "
Source: #{response.documentable.external_link}
"
end
formatted_response
end
end
@@ -0,0 +1,24 @@
class Llm::BaseService
DEFAULT_MODEL = 'gpt-4o-mini'.freeze
def initialize
setup_ruby_llm
setup_model
rescue StandardError => e
raise "Failed to initialize LLM client: #{e.message}"
end
private
def setup_ruby_llm
api_key = InstallationConfig.find_by!(name: 'CAPTAIN_OPEN_AI_API_KEY').value
::RubyLLM.configure do |config|
config.openai_api_key = api_key
end
end
def setup_model
config_value = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value
@model = (config_value.presence || DEFAULT_MODEL)
end
end
@@ -3,12 +3,11 @@ require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:admin) { create(:user, account: account, role: :administrator) }
let!(:portal) { create(:portal, name: 'test_portal', account_id: account.id) }
let!(:category) { create(:category, name: 'category', portal: portal, account_id: account.id, locale: 'en', slug: 'category_slug') }
let!(:article) { create(:article, category: category, portal: portal, account_id: account.id, author_id: agent.id) }
before { create(:portal_member, user: agent, portal: portal) }
describe 'POST /api/v1/accounts/{account.id}/portals/{portal.slug}/articles' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
@@ -33,7 +32,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
}
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
params: article_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eql('MyTitle')
@@ -56,7 +55,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
}
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
params: article_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eql('MyTitle')
@@ -84,7 +83,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
}
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
params: article_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eql('MyTitle')
@@ -110,7 +109,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
}
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
params: article_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eql('MyTitle')
@@ -144,7 +143,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
params: article_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eql(article_params[:article][:title])
@@ -165,7 +164,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
context 'when it is an authenticated user' do
it 'deletes category' do
delete "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
deleted_article = Article.find_by(id: article.id)
expect(deleted_article).to be_nil
@@ -187,7 +186,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
expect(article2.id).not_to be_nil
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
headers: agent.create_new_auth_token,
headers: admin.create_new_auth_token,
params: {}
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -199,7 +198,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
expect(article2.id).not_to be_nil
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
headers: agent.create_new_auth_token,
headers: admin.create_new_auth_token,
params: {}
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -213,7 +212,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
expect(article2.id).not_to be_nil
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
headers: agent.create_new_auth_token,
headers: admin.create_new_auth_token,
params: { category_slug: category.slug }
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -230,14 +229,14 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
expect(article2.id).not_to be_nil
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
headers: agent.create_new_auth_token,
headers: admin.create_new_auth_token,
params: { query: 'funny' }
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload'].count).to be 1
expect(json_response['meta']['all_articles_count']).to be 2
expect(json_response['meta']['articles_count']).to be 1
expect(json_response['meta']['mine_articles_count']).to be 1
expect(json_response['meta']['mine_articles_count']).to be 0
end
end
@@ -247,7 +246,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
expect(article2.id).not_to be_nil
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article2.id}",
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -263,7 +262,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
associated_article_id: root_article.id)
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{root_article.id}",
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -3,6 +3,7 @@ require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:admin) { create(:user, account: account, role: :administrator) }
let!(:portal) { create(:portal, name: 'test_portal', account_id: account.id, config: { allowed_locales: %w[en es] }) }
let!(:category) { create(:category, name: 'category', portal: portal, account_id: account.id, slug: 'category_slug', position: 1) }
let!(:category_to_associate) do
@@ -15,8 +16,6 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
create(:category, name: 'related category 2', portal: portal, account_id: account.id, slug: 'category_slug_2', position: 4)
end
before { create(:portal_member, user: agent, portal: portal) }
describe 'POST /api/v1/accounts/{account.id}/portals/{portal.slug}/categories' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
@@ -59,7 +58,7 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
it 'creates category' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -75,11 +74,11 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
it 'creates multiple sub_categories under one parent_category' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params_2,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
expect(category.reload.sub_category_ids).to eql(Category.last(2).pluck(:id))
@@ -88,11 +87,11 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
it 'creates multiple associated_categories with one category' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params_2,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
expect(category_to_associate.reload.associated_category_ids).to eql(Category.last(2).pluck(:id))
@@ -101,11 +100,11 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
it 'will throw an error on locale, category_id uniqueness' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['message']).to eql('Locale should be unique in the category and portal')
@@ -123,7 +122,7 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
@@ -158,7 +157,7 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{category.id}",
params: category_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
json_response = response.parsed_body
@@ -181,7 +180,7 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{category.id}",
params: category_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
@@ -209,7 +208,7 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{related_category_2.id}",
params: category_params,
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
@@ -230,7 +229,7 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
context 'when it is an authenticated user' do
it 'deletes category' do
delete "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{category.id}",
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
deleted_category = Category.find_by(id: category.id)
expect(deleted_category).to be_nil
@@ -255,7 +254,7 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
expect(category2.id).not_to be_nil
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload'].count).to be(category_count + 1)
@@ -8,8 +8,6 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
let(:agent_2) { create(:user, account: account, role: :agent) }
let!(:portal) { create(:portal, slug: 'portal-1', name: 'test_portal', account_id: account.id) }
before { create(:portal_member, user: agent, portal: portal) }
describe 'GET /api/v1/accounts/{account.id}/portals' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
@@ -23,7 +21,7 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
portal2 = create(:portal, name: 'test_portal_2', account_id: account.id, slug: 'portal-2')
expect(portal2.id).not_to be_nil
get "/api/v1/accounts/#{account.id}/portals",
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -45,7 +43,7 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
context 'when it is an authenticated user' do
it 'get one portals' do
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -62,7 +60,7 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
create(:article, category_id: es_cat.id, portal_id: portal.id, author_id: agent.id)
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}?locale=en",
headers: agent.create_new_auth_token
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -178,38 +176,7 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
end
end
describe 'PUT /api/v1/accounts/{account.id}/portals/{portal.slug}/add_members' do
let(:new_account) { create(:account) }
let(:new_agent) { create(:user, account: new_account, role: :agent) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/add_members", params: {}
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
it 'add members to the portal' do
portal_params = {
portal: {
member_ids: [agent_1.id, agent_2.id]
}
}
expect(portal.members.count).to be(1)
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/add_members",
params: portal_params,
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(portal.reload.member_ids).to include(agent_1.id)
expect(json_response['portal_members'].length).to be(3)
end
end
end
# Portal members endpoint removed
describe 'DELETE /api/v1/accounts/{account.id}/portals/{portal.slug}/logo' do
context 'when it is an unauthenticated user' do
@@ -0,0 +1,103 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Enterprise Articles API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, :administrator, account: account) }
let(:agent) { create(:user, account: account, role: :agent) }
let!(:portal) { create(:portal, name: 'test_portal', account_id: account.id) }
let!(:category) { create(:category, name: 'category', portal: portal, account_id: account.id, locale: 'en', slug: 'category_slug') }
let!(:article) { create(:article, category: category, portal: portal, account_id: account.id, author_id: admin.id) }
# Create a custom role with knowledge_base_manage permission
let!(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
# Create user without account
let!(:agent_with_role) { create(:user) }
# Then create account_user association with custom_role
let(:agent_with_role_account_user) do
create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
end
# Ensure the account_user with custom role is created before tests run
before do
agent_with_role_account_user
end
describe 'GET /api/v1/accounts/:account_id/portals/:portal_slug/articles/:id' do
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
end
end
end
describe 'POST /api/v1/accounts/:account_id/portals/:portal_slug/articles' do
let(:article_params) do
{
article: {
category_id: category.id,
title: 'New Article',
slug: 'new-article',
content: 'This is a new article',
author_id: agent_with_role.id,
status: 'draft'
}
}
end
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
params: article_params,
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eq('New Article')
end
end
end
describe 'PUT /api/v1/accounts/:account_id/portals/:portal_slug/articles/:id' do
let(:article_params) do
{
article: {
title: 'Updated Article',
content: 'This is an updated article'
}
}
end
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
params: article_params,
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eq('Updated Article')
end
end
end
describe 'DELETE /api/v1/accounts/:account_id/portals/:portal_slug/articles/:id' do
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
delete "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(Article.find_by(id: article.id)).to be_nil
end
end
end
end
@@ -0,0 +1,111 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Enterprise Categories API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let!(:portal) { create(:portal, name: 'test_portal', account_id: account.id, config: { allowed_locales: %w[en es] }) }
let!(:category) { create(:category, name: 'category', portal: portal, account_id: account.id, slug: 'category_slug', position: 1) }
# Create a custom role with knowledge_base_manage permission
let!(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
let!(:agent_with_role) { create(:user) }
let(:agent_with_role_account_user) do
create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
end
# Ensure the account_user with custom role is created before tests run
before do
agent_with_role_account_user
end
describe 'GET /api/v1/accounts/:account_id/portals/:portal_slug/categories' do
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
end
end
end
describe 'GET /api/v1/accounts/:account_id/portals/:portal_slug/categories/:id' do
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{category.id}",
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['name']).to eq('category')
end
end
end
describe 'POST /api/v1/accounts/:account_id/portals/:portal_slug/categories' do
let(:category_params) do
{
category: {
name: 'New Category',
slug: 'new-category',
locale: 'en',
description: 'This is a new category'
}
}
end
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params,
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['name']).to eq('New Category')
end
end
end
describe 'PUT /api/v1/accounts/:account_id/portals/:portal_slug/categories/:id' do
let(:category_params) do
{
category: {
name: 'Updated Category',
description: 'This is an updated category'
}
}
end
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{category.id}",
params: category_params,
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['name']).to eq('Updated Category')
end
end
end
describe 'DELETE /api/v1/accounts/:account_id/portals/:portal_slug/categories/:id' do
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
delete "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{category.id}",
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
end
end
end
end
@@ -0,0 +1,90 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Enterprise Portal API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, :administrator, account: account) }
let(:agent) { create(:user, account: account, role: :agent) }
let!(:portal) { create(:portal, name: 'test_portal', account_id: account.id) }
# Create a custom role with knowledge_base_manage permission
let!(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
# Create user without account
let!(:agent_with_role) { create(:user) }
# Then create account_user association with custom_role
let(:agent_with_role_account_user) do
create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
end
# Ensure the account_user with custom role is created before tests run
before do
agent_with_role_account_user
end
describe 'GET /api/v1/accounts/:account_id/portals' do
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
get "/api/v1/accounts/#{account.id}/portals",
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
end
end
end
describe 'GET /api/v1/accounts/:account_id/portals/:portal_slug' do
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['name']).to eq('test_portal')
end
end
end
describe 'POST /api/v1/accounts/:account_id/portals' do
let(:portal_params) do
{ portal: {
name: 'test_portal',
slug: 'test_kbase',
custom_domain: 'https://support.chatwoot.dev'
} }
end
context 'when it is an authenticated user' do
it 'restricts portal creation for agents with knowledge_base_manage permission' do
post "/api/v1/accounts/#{account.id}/portals",
params: portal_params,
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'PUT /api/v1/accounts/:account_id/portals/:portal_slug' do
let(:portal_params) do
{ portal: { name: 'updated_portal' } }
end
context 'when it is an authenticated user' do
it 'returns success for agents with knowledge_base_manage permission' do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
params: portal_params,
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['name']).to eq('updated_portal')
end
end
end
end
@@ -0,0 +1,67 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Enterprise Reports API', type: :request do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
# Create a custom role with report_manage permission
let!(:custom_role) { create(:custom_role, account: account, permissions: ['report_manage']) }
let!(:agent_with_role) { create(:user) }
let(:agent_with_role_account_user) do
create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
end
let(:default_timezone) { 'UTC' }
let(:start_of_today) { Time.current.in_time_zone(default_timezone).beginning_of_day.to_i }
let(:end_of_today) { Time.current.in_time_zone(default_timezone).end_of_day.to_i }
let(:params) { { timezone_offset: Time.zone.utc_offset } }
before do
agent_with_role_account_user
end
describe 'GET /api/v2/accounts/:account_id/reports' do
context 'when it is an authenticated user' do
let(:params) do
super().merge(
metric: 'conversations_count',
type: :account,
since: start_of_today.to_s,
until: end_of_today.to_s
)
end
it 'returns success for agents with report_manage permission' do
get "/api/v2/accounts/#{account.id}/reports",
params: params,
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
end
end
end
describe 'GET /api/v2/accounts/:account_id/reports/summary' do
context 'when it is an authenticated user' do
let(:params) do
super().merge(
type: :account,
since: start_of_today.to_s,
until: end_of_today.to_s
)
end
it 'returns success for agents with report_manage permission' do
get "/api/v2/accounts/#{account.id}/reports/summary",
params: params,
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
end
end
end
end
@@ -0,0 +1,28 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Enterprise::ArticlePolicy', type: :policy do
subject(:article_policy) { ArticlePolicy }
let(:account) { create(:account) }
let(:agent) { create(:user, account: account) } # Needed for author
let(:portal) { create(:portal, account: account) }
let(:article) { create(:article, account: account, portal: portal, author: agent) }
# Create a custom role with knowledge_base_manage permission
let(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
let(:agent_with_role) { create(:user) } # Create without account
let(:agent_with_role_account_user) do
create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
end
let(:agent_with_role_context) do
{ user: agent_with_role, account: account, account_user: agent_with_role_account_user }
end
permissions :index?, :update?, :show?, :edit?, :create?, :destroy?, :reorder? do
context 'when agent with knowledge_base_manage permission' do
it { expect(article_policy).to permit(agent_with_role_context, article) }
end
end
end
@@ -0,0 +1,27 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Enterprise::CategoryPolicy', type: :policy do
subject(:category_policy) { CategoryPolicy }
let(:account) { create(:account) }
let(:portal) { create(:portal, account: account) }
let(:category) { create(:category, account: account, portal: portal, slug: 'test-category') }
# Create a custom role with knowledge_base_manage permission
let(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
let(:agent_with_role) { create(:user) } # Create without account
let(:agent_with_role_account_user) do
create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
end
let(:agent_with_role_context) do
{ user: agent_with_role, account: account, account_user: agent_with_role_account_user }
end
permissions :index?, :update?, :show?, :edit?, :create?, :destroy? do
context 'when agent with knowledge_base_manage permission' do
it { expect(category_policy).to permit(agent_with_role_context, category) }
end
end
end
@@ -0,0 +1,32 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Enterprise::PortalPolicy', type: :policy do
subject(:portal_policy) { PortalPolicy }
let(:account) { create(:account) }
let(:portal) { create(:portal, account: account) }
# Create a custom role with knowledge_base_manage permission
let(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
let(:agent_with_role) { create(:user) } # Create without account
let(:agent_with_role_account_user) do
create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
end
let(:agent_with_role_context) do
{ user: agent_with_role, account: account, account_user: agent_with_role_account_user }
end
permissions :update?, :edit?, :logo? do
context 'when agent with knowledge_base_manage permission' do
it { expect(portal_policy).to permit(agent_with_role_context, portal) }
end
end
permissions :create?, :destroy? do
context 'when agent with knowledge_base_manage permission' do
it { expect(portal_policy).not_to permit(agent_with_role_context, portal) }
end
end
end
@@ -0,0 +1,26 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Enterprise::ReportPolicy', type: :policy do
subject(:report_policy) { ReportPolicy }
let(:account) { create(:account) }
let(:report) { :report }
# Create a custom role with report_manage permission
let(:custom_role) { create(:custom_role, account: account, permissions: ['report_manage']) }
let(:agent_with_role) { create(:user) } # Create without account
let(:agent_with_role_account_user) do
create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
end
let(:agent_with_role_context) do
{ user: agent_with_role, account: account, account_user: agent_with_role_account_user }
end
permissions :view? do
context 'when agent with report_manage permission' do
it { expect(report_policy).to permit(agent_with_role_context, report) }
end
end
end
@@ -0,0 +1,131 @@
require 'rails_helper'
require 'ruby_llm'
RSpec.describe Captain::Llm::AssistantChatService do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account, config: { 'product_name' => 'Chatwoot' }) }
let(:service) { described_class.new(assistant: assistant) }
let(:chat) { instance_double(RubyLLM::Chat) }
let(:chat_response) { instance_double(RubyLLM::Message) }
let(:embedding) { Array.new(1536) { rand(-1.0..1.0) } }
let(:embedding_service) { instance_double(Captain::Llm::EmbeddingService) }
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
allow(RubyLLM).to receive(:chat).and_return(chat)
allow(chat).to receive(:with_tool).with(Captain::Tools::DocumentationSearch).and_return(chat)
allow(chat).to receive(:with_instructions)
allow(chat).to receive(:add_message)
allow(Captain::Llm::UpdateEmbeddingJob).to receive(:perform_later)
allow(Captain::Llm::EmbeddingService).to receive(:new).and_return(embedding_service)
allow(embedding_service).to receive(:get_embedding).and_return(embedding)
end
describe '#initialize' do
it 'sets up RubyLLM chat with DocumentationSearch tool' do
expect(RubyLLM).to receive(:chat).with(model: anything)
expect(chat).to receive(:with_tool).with(Captain::Tools::DocumentationSearch)
service
end
it 'sets system message' do
expect(chat).to receive(:with_instructions).with(
Captain::Llm::SystemPromptsService.assistant_response_generator('Chatwoot')
)
service
end
end
describe '#generate_response' do
let(:input) { 'How do I configure inbox?' }
let(:previous_messages) do
[
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there!' }
]
end
before do
allow(chat).to receive(:ask).and_return(chat_response)
previous_messages.each do |msg|
if msg[:role] == 'system'
allow(chat).to receive(:with_instructions).with(msg[:content])
else
allow(chat).to receive(:add_message).with(role: msg[:role], content: msg[:content])
end
end
end
context 'when response is valid JSON' do
let(:json_response) do
{
'reasoning' => 'Found in documentation',
'response' => 'Here are the steps...'
}
end
before do
allow(chat_response).to receive(:content).and_return(json_response.to_json)
end
it 'returns parsed JSON response' do
result = service.generate_response(input, previous_messages)
expect(result).to eq(json_response)
end
end
context 'when response indicates conversation handoff' do
before do
allow(chat_response).to receive(:content).and_return('conversation_handoff')
end
it 'returns conversation_handoff' do
result = service.generate_response(input, previous_messages)
expect(result).to eq('conversation_handoff')
end
end
context 'when response is not valid JSON' do
let(:plain_response) { 'Here are the steps...' }
before do
allow(chat_response).to receive(:content).and_return(plain_response)
end
it 'wraps response in JSON format' do
result = service.generate_response(input, previous_messages)
expect(result).to eq({
'reasoning' => '',
'response' => plain_response
})
end
end
context 'when input is blank' do
let(:input) { '' }
it 'does not send user message' do
expect(chat).not_to receive(:ask)
service.generate_response(input, previous_messages)
end
end
context 'when no previous messages' do
let(:response_content) { 'Here are the steps...' }
before do
# Clear the service instance to avoid system message being counted
allow(chat).to receive(:with_instructions).with(
Captain::Llm::SystemPromptsService.assistant_response_generator('Chatwoot')
)
allow(chat_response).to receive(:content).and_return(response_content)
end
it 'only sends new input' do
expect(chat).not_to receive(:add_message)
expect(chat).to receive(:ask).with(input)
service.generate_response(input)
end
end
end
end
@@ -0,0 +1,148 @@
require 'rails_helper'
RSpec.describe Captain::Llm::ContactNotesService do
let(:captain_assistant) { create(:captain_assistant) }
let(:account) { create(:account, locale: 'en') }
let(:conversation) { create(:conversation, account: account) }
let(:contact) { conversation.contact }
let(:service) { described_class.new(captain_assistant, conversation) }
let(:client) { instance_double(OpenAI::Client) }
let(:system_prompt) { 'Test system prompt' }
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
allow(OpenAI::Client).to receive(:new).and_return(client)
allow(Captain::Llm::SystemPromptsService).to receive(:notes_generator)
.with(account.locale_english_name).and_return(system_prompt)
end
describe '#generate_and_update_notes' do
let(:openai_response) do
{
'choices' => [
{
'message' => {
'content' => {
notes: [
'Customer reported an issue with login',
'Follow up needed on billing concerns'
]
}.to_json
}
}
]
}
end
context 'when successful' do
before do
allow(client).to receive(:chat).and_return(openai_response)
end
it 'creates notes for the contact' do
expect { service.generate_and_update_notes }.to change(contact.notes, :count).by(2)
end
it 'creates notes with correct content' do
service.generate_and_update_notes
expect(contact.notes.pluck(:content)).to contain_exactly('Customer reported an issue with login', 'Follow up needed on billing concerns')
end
it 'includes conversation and contact context in chat parameters' do
service.generate_and_update_notes
expect(client).to have_received(:chat) do |params|
messages = params[:parameters][:messages]
content = messages.find { |m| m[:role] == 'user' }[:content]
expect(content).to include('#Contact')
expect(content).to include('#Conversation')
end
end
it 'includes system message in chat parameters' do
service.generate_and_update_notes
expect(client).to have_received(:chat) do |params|
messages = params[:parameters][:messages]
system_message = messages.find { |m| m[:role] == 'system' }
expect(system_message[:content]).to eq(system_prompt)
end
end
end
context 'when OpenAI API fails' do
before do
allow(client).to receive(:chat).and_raise(OpenAI::Error.new('API Error'))
end
it 'logs error and returns empty array' do
expect(Rails.logger).to receive(:error).with('OpenAI API Error: API Error')
expect { service.generate_and_update_notes }.not_to change(contact.notes, :count)
end
end
context 'when response parsing fails' do
let(:invalid_response) do
{
'choices' => [
{
'message' => {
'content' => 'Invalid JSON'
}
}
]
}
end
before do
allow(client).to receive(:chat).and_return(invalid_response)
end
it 'logs error and returns empty array' do
expect(Rails.logger).to receive(:error).with(/Error in parsing GPT processed response/)
expect { service.generate_and_update_notes }.not_to change(contact.notes, :count)
end
end
context 'when response is missing content' do
let(:empty_response) do
{
'choices' => [
{
'message' => {}
}
]
}
end
before do
allow(client).to receive(:chat).and_return(empty_response)
end
it 'returns empty array' do
expect { service.generate_and_update_notes }.not_to change(contact.notes, :count)
end
end
end
describe '#chat_parameters' do
before do
allow(client).to receive(:chat).and_return({
'choices' => [
{
'message' => {
'content' => { notes: [] }.to_json
}
}
]
})
end
it 'includes correct model and response format' do
service.generate_and_update_notes
expect(client).to have_received(:chat) do |params|
parameters = params[:parameters]
expect(parameters[:model]).to eq('gpt-4o-mini')
expect(parameters[:response_format]).to eq({ type: 'json_object' })
end
end
end
end
@@ -0,0 +1,69 @@
require 'rails_helper'
RSpec.describe Captain::Llm::EmbeddingService do
let(:service) { described_class.new }
let(:client) { instance_double(OpenAI::Client) }
let(:content) { 'Test content for embedding' }
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
allow(OpenAI::Client).to receive(:new).and_return(client)
end
describe '#get_embedding' do
let(:embedding) { [0.1, 0.2, 0.3] }
let(:openai_response) do
{
'data' => [
{
'embedding' => embedding
}
]
}
end
context 'when successful' do
before do
allow(client).to receive(:embeddings).and_return(openai_response)
end
it 'returns embedding array' do
expect(service.get_embedding(content)).to eq(embedding)
end
it 'uses default model' do
service.get_embedding(content)
expect(client).to have_received(:embeddings).with(
parameters: {
model: described_class::DEFAULT_MODEL,
input: content
}
)
end
it 'accepts custom model' do
custom_model = 'text-embedding-ada-002'
service.get_embedding(content, model: custom_model)
expect(client).to have_received(:embeddings).with(
parameters: {
model: custom_model,
input: content
}
)
end
end
context 'when API call fails' do
before do
allow(client).to receive(:embeddings).and_raise(StandardError.new('API Error'))
end
it 'raises EmbeddingsError with message' do
expect { service.get_embedding(content) }.to raise_error(
Captain::Llm::EmbeddingService::EmbeddingsError,
'Failed to create an embedding: API Error'
)
end
end
end
end
@@ -0,0 +1,154 @@
require 'rails_helper'
RSpec.describe Captain::Llm::FaqGeneratorService do
let(:content) { 'How do I reset my password? Click on forgot password link.' }
let(:service) { described_class.new(content) }
let(:client) { instance_double(OpenAI::Client) }
let(:system_prompt) { 'Test system prompt' }
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
allow(OpenAI::Client).to receive(:new).and_return(client)
allow(Captain::Llm::SystemPromptsService).to receive(:faq_generator).and_return(system_prompt)
end
describe '#generate' do
let(:openai_response) do
{
'choices' => [
{
'message' => {
'content' => {
faqs: [
{
question: 'How do I reset my password?',
answer: 'Click on forgot password link.'
},
{
question: 'Where is the forgot password link?',
answer: 'The forgot password link is on the login page.'
}
]
}.to_json
}
}
]
}
end
context 'when successful' do
before do
allow(client).to receive(:chat).and_return(openai_response)
end
it 'generates FAQs from content' do
faqs = service.generate
expect(faqs).to contain_exactly(
{
'question' => 'How do I reset my password?',
'answer' => 'Click on forgot password link.'
},
{
'question' => 'Where is the forgot password link?',
'answer' => 'The forgot password link is on the login page.'
}
)
end
it 'includes content in chat parameters' do
service.generate
expect(client).to have_received(:chat) do |params|
messages = params[:parameters][:messages]
user_message = messages.find { |m| m[:role] == 'user' }
expect(user_message[:content]).to eq(content)
end
end
it 'includes system message in chat parameters' do
service.generate
expect(client).to have_received(:chat) do |params|
messages = params[:parameters][:messages]
system_message = messages.find { |m| m[:role] == 'system' }
expect(system_message[:content]).to eq(system_prompt)
end
end
end
context 'when OpenAI API fails' do
before do
allow(client).to receive(:chat).and_raise(OpenAI::Error.new('API Error'))
end
it 'logs error and returns empty array' do
expect(Rails.logger).to receive(:error).with('OpenAI API Error: API Error')
expect(service.generate).to eq([])
end
end
context 'when response parsing fails' do
let(:invalid_response) do
{
'choices' => [
{
'message' => {
'content' => 'Invalid JSON'
}
}
]
}
end
before do
allow(client).to receive(:chat).and_return(invalid_response)
end
it 'logs error and returns empty array' do
expect(Rails.logger).to receive(:error).with(/Error in parsing GPT processed response/)
expect(service.generate).to eq([])
end
end
context 'when response is missing content' do
let(:empty_response) do
{
'choices' => [
{
'message' => {}
}
]
}
end
before do
allow(client).to receive(:chat).and_return(empty_response)
end
it 'returns empty array' do
expect(service.generate).to eq([])
end
end
end
describe '#chat_parameters' do
before do
allow(client).to receive(:chat).and_return({
'choices' => [
{
'message' => {
'content' => { faqs: [] }.to_json
}
}
]
})
end
it 'includes correct model and response format' do
service.generate
expect(client).to have_received(:chat) do |params|
parameters = params[:parameters]
expect(parameters[:model]).to eq('gpt-4o-mini')
expect(parameters[:response_format]).to eq({ type: 'json_object' })
end
end
end
end
@@ -0,0 +1,90 @@
require 'rails_helper'
RSpec.describe Captain::Tools::DocumentationSearch do
let(:tool) { described_class.new }
let(:search_query) { 'how to configure inbox' }
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:embedding) { Array.new(1536) { rand(-1.0..1.0) } }
let(:embedding_service) { instance_double(Captain::Llm::EmbeddingService) }
before do
allow(Captain::Llm::UpdateEmbeddingJob).to receive(:perform_later)
allow(Captain::Llm::EmbeddingService).to receive(:new).and_return(embedding_service)
allow(embedding_service).to receive(:get_embedding).with(search_query).and_return(embedding)
end
describe '#execute' do
context 'when matching responses exist' do
let!(:response) do
create(:captain_assistant_response,
assistant: assistant,
account: account,
question: 'How do I configure my inbox?',
answer: 'Follow these steps...',
status: :approved,
embedding: embedding)
end
let!(:response_with_source) do
document = create(:captain_document,
assistant: assistant,
account: account,
external_link: 'https://example.com/docs')
create(:captain_assistant_response,
assistant: assistant,
account: account,
question: 'What are inbox settings?',
answer: 'Inbox settings include...',
documentable: document,
status: :approved,
embedding: embedding)
end
it 'returns formatted responses' do
result = tool.execute(search_query: search_query)
expect(result).to be_an(Array)
expect(result.size).to eq(2)
# First response without source
expect(result[0]).to include(
question: response.question,
answer: response.answer
)
expect(result[0]).not_to have_key(:source)
# Second response with source
expect(result[1]).to include(
question: response_with_source.question,
answer: response_with_source.answer,
source: 'https://example.com/docs'
)
end
end
context 'when no matching responses exist' do
it 'returns an empty array' do
result = tool.execute(search_query: search_query)
expect(result).to eq([])
end
end
end
describe '.description' do
it 'has a description' do
expect(described_class.description).to eq('Search through the documentation to find relevant answers')
end
end
describe '.parameters' do
it 'defines search_query parameter' do
param = described_class.parameters[:search_query]
expect(param).to be_a(RubyLLM::Parameter)
expect(param.name).to eq(:search_query)
expect(param.type).to eq(:string)
expect(param.description).to eq('The search query to find relevant documentation')
expect(param.required).to be true
end
end
end
@@ -0,0 +1,55 @@
require 'rails_helper'
RSpec.describe Llm::BaseService do
describe '#initialize' do
let(:api_key) { 'test-key' }
let(:model) { 'custom-model' }
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: api_key)
end
it 'configures RubyLLM with the API key' do
expect(RubyLLM).to receive(:configure) do |&block|
config = OpenStruct.new
block.call(config)
expect(config.openai_api_key).to eq(api_key)
end
described_class.new
end
context 'when API key is missing' do
before do
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY').destroy
end
it 'raises an error' do
expect { described_class.new }.to raise_error(
RuntimeError,
/Failed to initialize LLM client: Couldn't find InstallationConfig/
)
end
end
context 'when model config exists' do
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: model)
end
it 'uses the configured model' do
allow(RubyLLM).to receive(:configure)
service = described_class.new
expect(service.instance_variable_get(:@model)).to eq(model)
end
end
context 'when model config is missing' do
it 'uses the default model' do
allow(RubyLLM).to receive(:configure)
service = described_class.new
expect(service.instance_variable_get(:@model)).to eq(described_class::DEFAULT_MODEL)
end
end
end
end
+1
View File
@@ -3,5 +3,6 @@ FactoryBot.define do
sequence(:name) { |n| "Assistant #{n}" }
description { 'Test description' }
association :account
config { { 'product_name' => 'Test Product', 'feature_memory' => true, 'feature_faq' => true } }
end
end
-6
View File
@@ -1,6 +0,0 @@
FactoryBot.define do
factory :portal_member do
portal
user
end
end
-8
View File
@@ -1,8 +0,0 @@
require 'rails_helper'
RSpec.describe PortalMember do
describe 'associations' do
it { is_expected.to belong_to(:portal) }
it { is_expected.to belong_to(:user) }
end
end
-2
View File
@@ -12,8 +12,6 @@ RSpec.describe Portal do
it { is_expected.to have_many(:categories) }
it { is_expected.to have_many(:folders) }
it { is_expected.to have_many(:articles) }
it { is_expected.to have_many(:portal_members) }
it { is_expected.to have_many(:members) }
it { is_expected.to have_many(:inboxes) }
end
+34
View File
@@ -0,0 +1,34 @@
require 'rails_helper'
RSpec.describe ArticlePolicy, type: :policy do
subject(:article_policy) { described_class }
let(:account) { create(:account) }
let(:administrator) { create(:user, :administrator, account: account) }
let(:agent) { create(:user, account: account) }
let(:portal) { create(:portal, account: account) }
let(:article) { create(:article, account: account, portal: portal, author: administrator) }
let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
permissions :index? do
context 'when administrator' do
it { expect(article_policy).to permit(administrator_context, article) }
end
context 'when agent' do
it { expect(article_policy).to permit(agent_context, article) }
end
end
permissions :update?, :show?, :edit?, :create?, :destroy?, :reorder? do
context 'when administrator' do
it { expect(article_policy).to permit(administrator_context, article) }
end
context 'when agent' do
it { expect(article_policy).not_to permit(agent_context, article) }
end
end
end
+34
View File
@@ -0,0 +1,34 @@
require 'rails_helper'
RSpec.describe CategoryPolicy, type: :policy do
subject(:category_policy) { described_class }
let(:account) { create(:account) }
let(:administrator) { create(:user, :administrator, account: account) }
let(:agent) { create(:user, account: account) }
let(:portal) { create(:portal, account: account) }
let(:category) { create(:category, account: account, portal: portal, slug: 'test-category') }
let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
permissions :index? do
context 'when administrator' do
it { expect(category_policy).to permit(administrator_context, category) }
end
context 'when agent' do
it { expect(category_policy).to permit(agent_context, category) }
end
end
permissions :update?, :show?, :edit?, :create?, :destroy? do
context 'when administrator' do
it { expect(category_policy).to permit(administrator_context, category) }
end
context 'when agent' do
it { expect(category_policy).not_to permit(agent_context, category) }
end
end
end
+35
View File
@@ -0,0 +1,35 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe PortalPolicy, type: :policy do
subject(:portal_policy) { described_class }
let(:account) { create(:account) }
let(:administrator) { create(:user, :administrator, account: account) }
let(:agent) { create(:user, account: account) }
let(:portal) { create(:portal, account: account) }
let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
permissions :index?, :show? do
context 'when administrator' do
it { expect(portal_policy).to permit(administrator_context, portal) }
end
context 'when agent' do
it { expect(portal_policy).to permit(agent_context, portal) }
end
end
permissions :update?, :edit?, :create?, :destroy?, :logo? do
context 'when administrator' do
it { expect(portal_policy).to permit(administrator_context, portal) }
end
context 'when agent' do
it { expect(portal_policy).not_to permit(agent_context, portal) }
end
end
end
+25
View File
@@ -0,0 +1,25 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ReportPolicy, type: :policy do
subject(:report_policy) { described_class }
let(:account) { create(:account) }
let(:administrator) { create(:user, :administrator, account: account) }
let(:agent) { create(:user, account: account) }
let(:report) { :report }
let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
permissions :view? do
context 'when administrator' do
it { expect(report_policy).to permit(administrator_context, report) }
end
context 'when agent' do
it { expect(report_policy).not_to permit(agent_context, report) }
end
end
end