Compare commits

..
Author SHA1 Message Date
Sojan Jose da0a2661ac feat: add captain conversation ownership 2026-06-28 20:30:24 -07:00
14 changed files with 132 additions and 190 deletions
@@ -1,7 +1,7 @@
class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController
# assigns agent/team to a conversation
def create
if params.key?(:assignee_id) || agent_bot_assignment?
if params.key?(:assignee_id) || ai_assignment?
set_agent
elsif params.key?(:team_id)
set_team
@@ -28,6 +28,8 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: resource }
when AgentBot
render partial: 'api/v1/models/agent_bot_slim', formats: [:json], locals: { resource: resource }
when Captain::Assistant
render partial: 'api/v1/models/captain_assistant_slim', formats: [:json], locals: { resource: resource }
else
render json: nil
end
@@ -39,7 +41,7 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
render json: @team
end
def agent_bot_assignment?
params[:assignee_type].to_s == 'AgentBot'
def ai_assignment?
params[:assignee_type].to_s.in?(%w[AgentBot CaptainAssistant])
end
end
+9 -5
View File
@@ -21,6 +21,7 @@
# updated_at :datetime not null
# account_id :integer not null
# assignee_agent_bot_id :bigint
# assignee_captain_assistant_id :bigint
# assignee_id :integer
# campaign_id :bigint
# contact_id :bigint
@@ -66,7 +67,7 @@ class Conversation < ApplicationRecord
validates :inbox_id, presence: true
validates :contact_id, presence: true
before_validation :validate_additional_attributes
before_validation :reset_agent_bot_when_assignee_present
before_validation :reset_ai_assignees_when_assignee_present
validates :additional_attributes, jsonb_attributes_length: true
validates :custom_attributes, jsonb_attributes_length: true
validates :uuid, uniqueness: true
@@ -104,6 +105,7 @@ class Conversation < ApplicationRecord
belongs_to :inbox
belongs_to :assignee, class_name: 'User', optional: true, inverse_of: :assigned_conversations
belongs_to :assignee_agent_bot, class_name: 'AgentBot', optional: true
belongs_to :assignee_captain_assistant, class_name: 'Captain::Assistant', optional: true
belongs_to :contact
belongs_to :contact_inbox
belongs_to :team, optional: true
@@ -195,6 +197,7 @@ class Conversation < ApplicationRecord
# Virtual attribute till we switch completely to polymorphic assignee
def assignee_type
return 'CaptainAssistant' if assignee_captain_assistant_id.present?
return 'AgentBot' if assignee_agent_bot_id.present?
return 'User' if assignee_id.present?
@@ -202,7 +205,7 @@ class Conversation < ApplicationRecord
end
def assigned_entity
assignee_agent_bot || assignee
assignee_captain_assistant || assignee_agent_bot || assignee
end
def tweet?
@@ -271,10 +274,11 @@ class Conversation < ApplicationRecord
self.additional_attributes = {} unless additional_attributes.is_a?(Hash)
end
def reset_agent_bot_when_assignee_present
def reset_ai_assignees_when_assignee_present
return if assignee_id.blank?
self.assignee_agent_bot_id = nil
self.assignee_captain_assistant_id = nil
end
def determine_conversation_status
@@ -308,8 +312,8 @@ class Conversation < ApplicationRecord
end
def list_of_keys
%w[team_id assignee_id assignee_agent_bot_id status snoozed_until custom_attributes label_list waiting_since
first_reply_created_at priority]
%w[team_id assignee_id assignee_agent_bot_id assignee_captain_assistant_id status snoozed_until custom_attributes label_list
waiting_since first_reply_created_at priority]
end
def allowed_keys?
@@ -6,7 +6,10 @@ class Conversations::AssignmentService
end
def perform
agent_bot_assignment? ? assign_agent_bot : assign_agent
return assign_agent_bot if agent_bot_assignment?
return assign_captain_assistant if captain_assistant_assignment?
assign_agent
end
private
@@ -14,8 +17,11 @@ class Conversations::AssignmentService
attr_reader :conversation, :assignee_id, :assignee_type
def assign_agent
captain_owned = conversation.assignee_captain_assistant_id.present?
conversation.assignee = assignee
conversation.assignee_agent_bot = nil
conversation.assignee_captain_assistant = nil
conversation.status = :open if assignee.present? && captain_owned && conversation.pending?
conversation.save!
assignee
end
@@ -25,10 +31,22 @@ class Conversations::AssignmentService
conversation.assignee = nil
conversation.assignee_agent_bot = agent_bot
conversation.assignee_captain_assistant = nil
conversation.save!
agent_bot
end
def assign_captain_assistant
return unless captain_assistant
conversation.assignee = nil
conversation.assignee_agent_bot = nil
conversation.assignee_captain_assistant = captain_assistant
conversation.status = :pending
conversation.save!
captain_assistant
end
def assignee
@assignee ||= conversation.account.users.find_by(id: assignee_id)
end
@@ -37,7 +55,15 @@ class Conversations::AssignmentService
@agent_bot ||= AgentBot.accessible_to(conversation.account).find_by(id: assignee_id)
end
def captain_assistant
@captain_assistant ||= Captain::Assistant.for_account(conversation.account_id).find_by(id: assignee_id)
end
def agent_bot_assignment?
assignee_type.to_s == 'AgentBot'
end
def captain_assistant_assignment?
assignee_type.to_s == 'CaptainAssistant'
end
end
@@ -7,7 +7,12 @@ json.meta do
json.partial! 'api/v1/models/contact', formats: [:json], resource: conversation.contact
end
json.channel conversation.inbox.try(:channel_type)
if conversation.assigned_entity.is_a?(AgentBot)
if conversation.assignee_type == 'CaptainAssistant'
json.assignee do
json.partial! 'api/v1/models/captain_assistant_slim', formats: [:json], resource: conversation.assigned_entity
end
json.assignee_type 'CaptainAssistant'
elsif conversation.assigned_entity.is_a?(AgentBot)
json.assignee do
json.partial! 'api/v1/models/agent_bot_slim', formats: [:json], resource: conversation.assigned_entity
end
@@ -0,0 +1,4 @@
json.id resource.id
json.name resource.name
json.description resource.description
json.thumbnail resource.push_event_data[:avatar_url]
-18
View File
@@ -195,24 +195,6 @@
display_title: 'OpenAI API Endpoint (optional)'
description: 'The OpenAI endpoint configured for use in Captain AI. Default: https://api.openai.com/'
locked: false
- name: CAPTAIN_ANTHROPIC_API_KEY
display_title: 'Anthropic API Key'
description: 'The API key used to authenticate requests to Anthropic models for Captain AI.'
locked: false
type: secret
- name: CAPTAIN_ANTHROPIC_API_BASE
display_title: 'Anthropic API Base (optional)'
description: 'The Anthropic endpoint configured for use in Captain AI. Defaults to RubyLLM provider settings.'
locked: false
- name: CAPTAIN_GEMINI_API_KEY
display_title: 'Gemini API Key'
description: 'The API key used to authenticate requests to Gemini models for Captain AI.'
locked: false
type: secret
- name: CAPTAIN_GEMINI_API_BASE
display_title: 'Gemini API Base (optional)'
description: 'The Gemini endpoint configured for use in Captain AI. Defaults to RubyLLM provider settings.'
locked: false
- name: CAPTAIN_EMBEDDING_MODEL
display_title: 'Embedding Model (optional)'
description: 'The embedding model configured for use in Captain AI. Default: text-embedding-3-small'
@@ -0,0 +1,5 @@
class AddAssigneeCaptainAssistantToConversations < ActiveRecord::Migration[7.1]
def change
add_reference :conversations, :assignee_captain_assistant, type: :bigint, index: true
end
end
+3 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
ActiveRecord::Schema[7.1].define(version: 2026_06_28_000000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -726,11 +726,13 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
t.datetime "waiting_since"
t.text "cached_label_list"
t.bigint "assignee_agent_bot_id"
t.bigint "assignee_captain_assistant_id"
t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true
t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id"
t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx"
t.index ["account_id"], name: "index_conversations_on_account_id"
t.index ["assignee_id", "account_id"], name: "index_conversations_on_assignee_id_and_account_id"
t.index ["assignee_captain_assistant_id"], name: "index_conversations_on_assignee_captain_assistant_id"
t.index ["campaign_id"], name: "index_conversations_on_campaign_id"
t.index ["contact_id"], name: "index_conversations_on_contact_id"
t.index ["contact_inbox_id"], name: "index_conversations_on_contact_inbox_id"
+15 -93
View File
@@ -2,25 +2,11 @@ require 'ruby_llm'
module Llm::Config
DEFAULT_MODEL = 'gpt-4.1-mini'.freeze
DEFAULT_PROVIDER = 'openai'.freeze
PROVIDER_CONFIGS = {
'openai' => {
api_key: 'CAPTAIN_OPEN_AI_API_KEY',
api_base: 'CAPTAIN_OPEN_AI_ENDPOINT'
},
'anthropic' => {
api_key: 'CAPTAIN_ANTHROPIC_API_KEY',
api_base: 'CAPTAIN_ANTHROPIC_API_BASE'
},
'gemini' => {
api_key: 'CAPTAIN_GEMINI_API_KEY',
api_base: 'CAPTAIN_GEMINI_API_BASE'
}
}.freeze
class << self
def initialized? = @initialized ||= false
def initialized?
@initialized ||= false
end
def initialize!
return if @initialized
@@ -29,101 +15,37 @@ module Llm::Config
@initialized = true
end
def reset! = @initialized = false
def reset!
@initialized = false
end
def with_api_key(api_key, provider: DEFAULT_PROVIDER, api_base: nil)
def with_api_key(api_key, api_base: nil)
initialize!
context = RubyLLM.context do |config|
configure_provider(config, provider: provider, api_key: api_key, api_base: api_base)
config.openai_api_key = api_key
config.openai_api_base = api_base
end
yield context
end
def ruby_llm_provider_supported?(provider)
RubyLLM::Provider.providers.key?(provider.to_s.to_sym)
end
def provider_options
PROVIDER_CONFIGS.keys.each_with_object({}) do |provider, result|
next unless ruby_llm_provider_supported?(provider)
result[provider] = ruby_llm_provider_name(provider)
end
end
def api_key_for(provider)
installation_config_value(provider, :api_key)
end
def api_base_for(provider)
api_base = installation_config_value(provider, :api_base).presence
return if api_base.blank?
normalized_api_base(provider, api_base)
end
def provider_configured?(provider)
api_key_for(provider).present?
end
def openai_provider?(provider)
provider.to_s == DEFAULT_PROVIDER
end
def supports_tools_and_schema?(provider)
openai_provider?(provider)
end
def configure_provider(config, provider:, api_key:, api_base: nil)
provider = provider.to_s
options = provider_configuration_options(provider)
api_key_option = :"#{provider}_api_key"
api_base_option = :"#{provider}_api_base"
set_config_value(config, api_key_option, api_key) if api_key.present? && options.include?(api_key_option)
set_config_value(config, api_base_option, api_base) if api_base.present? && options.include?(api_base_option)
end
private
def configure_ruby_llm
RubyLLM.configure do |config|
PROVIDER_CONFIGS.each_key do |provider|
next unless ruby_llm_provider_supported?(provider)
configure_provider(config, provider: provider, api_key: api_key_for(provider), api_base: api_base_for(provider))
end
config.openai_api_key = system_api_key if system_api_key.present?
config.openai_api_base = openai_endpoint.chomp('/') if openai_endpoint.present?
config.model_registry_file = Rails.root.join('config/llm_models.json').to_s
config.logger = Rails.logger
end
end
def ruby_llm_provider_name(provider)
RubyLLM::Provider.providers[provider.to_s.to_sym].name
def system_api_key
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
end
def provider_configuration_options(provider)
RubyLLM::Provider.providers[provider.to_s.to_sym]&.configuration_options || []
end
def set_config_value(config, option, value)
setter = :"#{option}="
config.public_send(setter, value) if config.respond_to?(setter)
end
def installation_config_value(provider, key)
config_name = PROVIDER_CONFIGS.dig(provider.to_s, key)
return if config_name.blank?
InstallationConfig.find_by(name: config_name)&.value
end
def normalized_api_base(provider, api_base)
endpoint = api_base.chomp('/').delete_suffix('/chat/completions')
return "#{endpoint}/v1" if openai_provider?(provider) && endpoint.exclude?('/v1')
endpoint
def openai_endpoint
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value
end
end
end
+2 -16
View File
@@ -12,14 +12,11 @@ module Llm::Models
end
def default_model_for(feature)
default_model = features.dig(feature.to_s, 'default')
return default_model if supported_model?(default_model)
models_for(feature).first
features.dig(feature.to_s, 'default')
end
def models_for(feature)
(features.dig(feature.to_s, 'models') || []).select { |model_name| supported_model?(model_name) }
features.dig(feature.to_s, 'models') || []
end
def valid_model_for?(feature, model_name)
@@ -34,17 +31,6 @@ module Llm::Models
model_config(model_name)&.dig('provider')
end
def supported_provider?(provider)
providers.key?(provider.to_s) && Llm::Config.ruby_llm_provider_supported?(provider)
end
def supported_model?(model_name)
config = model_config(model_name)
return false unless config
supported_provider?(config['provider'])
end
def feature_config(feature_key)
feature = features[feature_key.to_s]
return nil unless feature
@@ -37,6 +37,7 @@ RSpec.describe 'Conversation Assignment API', type: :request do
context 'when it is an authenticated user with access to the inbox' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:agent_bot) { create(:agent_bot, account: account) }
let(:captain_assistant) { create(:captain_assistant, account: account) }
let(:team) { create(:team, account: account) }
before do
@@ -74,6 +75,27 @@ RSpec.describe 'Conversation Assignment API', type: :request do
expect(conversation.assignee).to be_nil
end
it 'assigns a Captain assistant to the conversation' do
params = { assignee_id: captain_assistant.id, assignee_type: 'CaptainAssistant' }
expect(Conversations::AssignmentService).to receive(:new)
.with(hash_including(conversation: conversation, assignee_id: captain_assistant.id, assignee_type: 'CaptainAssistant'))
.and_call_original
post api_v1_account_conversation_assignments_url(account_id: account.id, conversation_id: conversation.display_id),
params: params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['name']).to eq(captain_assistant.name)
conversation.reload
expect(conversation.assignee_captain_assistant).to eq(captain_assistant)
expect(conversation.assignee_agent_bot).to be_nil
expect(conversation.assignee).to be_nil
expect(conversation.status).to eq('pending')
end
it 'assigns a team to the conversation' do
team_member = create(:user, account: account, role: :agent, auto_offline: false)
create(:inbox_member, inbox: conversation.inbox, user: team_member)
-36
View File
@@ -1,36 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Llm::Config do
describe '.provider_options' do
it 'returns configured providers supported by RubyLLM' do
expect(described_class.provider_options).to include(
'openai' => 'OpenAI',
'anthropic' => 'Anthropic',
'gemini' => 'Gemini'
)
end
end
describe '.api_base_for' do
it 'normalizes OpenAI-compatible endpoints to the v1 base' do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://proxy.example.com/chat/completions')
expect(described_class.api_base_for('openai')).to eq('https://proxy.example.com/v1')
end
it 'keeps non-OpenAI provider endpoints unchanged except trailing slashes' do
create(:installation_config, name: 'CAPTAIN_ANTHROPIC_API_BASE', value: 'https://anthropic.example.com/')
expect(described_class.api_base_for('anthropic')).to eq('https://anthropic.example.com')
end
end
describe '.supports_tools_and_schema?' do
it 'allows tool and schema configuration only for OpenAI' do
expect(described_class.supports_tools_and_schema?('openai')).to be true
expect(described_class.supports_tools_and_schema?('anthropic')).to be false
end
end
end
-10
View File
@@ -40,16 +40,6 @@ RSpec.describe Llm::Models do
end
end
describe '.models_for' do
it 'filters out models whose provider is not supported by RubyLLM' do
allow(Llm::Config).to receive(:ruby_llm_provider_supported?) do |provider|
provider.to_s != 'anthropic'
end
expect(described_class.models_for('assistant')).not_to include('claude-haiku-4.5')
end
end
describe '.feature_config' do
it 'returns model metadata for a feature' do
config = described_class.feature_config('editor')
@@ -4,35 +4,39 @@ describe Conversations::AssignmentService do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account) }
let(:agent_bot) { create(:agent_bot, account: account) }
let(:captain_assistant) { create(:captain_assistant, account: account) }
let(:conversation) { create(:conversation, account: account) }
describe '#perform' do
context 'when assignee_id is blank' do
before do
conversation.update!(assignee: agent, assignee_agent_bot: agent_bot)
conversation.update!(assignee_agent_bot: agent_bot, assignee_captain_assistant: captain_assistant)
end
it 'clears both human and bot assignees' do
it 'clears human and AI assignees' do
described_class.new(conversation: conversation, assignee_id: nil).perform
conversation.reload
expect(conversation.assignee_id).to be_nil
expect(conversation.assignee_agent_bot_id).to be_nil
expect(conversation.assignee_captain_assistant_id).to be_nil
end
end
context 'when assigning a user' do
before do
conversation.update!(assignee_agent_bot: agent_bot, assignee: nil)
conversation.update!(assignee_captain_assistant: captain_assistant, assignee: nil, status: :pending)
end
it 'sets the agent and clears agent bot' do
it 'sets the agent and clears AI ownership' do
result = described_class.new(conversation: conversation, assignee_id: agent.id).perform
conversation.reload
expect(result).to eq(agent)
expect(conversation.assignee_id).to eq(agent.id)
expect(conversation.assignee_agent_bot_id).to be_nil
expect(conversation.assignee_captain_assistant_id).to be_nil
expect(conversation.status).to eq('open')
end
end
@@ -45,8 +49,8 @@ describe Conversations::AssignmentService do
)
end
it 'sets the agent bot and clears human assignee' do
conversation.update!(assignee: agent, assignee_agent_bot: nil)
it 'sets the agent bot and clears other assignees' do
conversation.update!(assignee_agent_bot: nil, assignee_captain_assistant: captain_assistant)
result = service.perform
@@ -54,6 +58,30 @@ describe Conversations::AssignmentService do
expect(result).to eq(agent_bot)
expect(conversation.assignee_agent_bot_id).to eq(agent_bot.id)
expect(conversation.assignee_id).to be_nil
expect(conversation.assignee_captain_assistant_id).to be_nil
end
end
context 'when assigning a Captain assistant' do
let(:service) do
described_class.new(
conversation: conversation,
assignee_id: captain_assistant.id,
assignee_type: 'CaptainAssistant'
)
end
it 'sets the Captain assistant, clears other assignees, and marks pending' do
conversation.update!(assignee_agent_bot: agent_bot, status: :resolved)
result = service.perform
conversation.reload
expect(result).to eq(captain_assistant)
expect(conversation.assignee_captain_assistant_id).to eq(captain_assistant.id)
expect(conversation.assignee_agent_bot_id).to be_nil
expect(conversation.assignee_id).to be_nil
expect(conversation.status).to eq('pending')
end
end
end