fix: merge develop and address PR review comments
This commit is contained in:
@@ -36,7 +36,7 @@ class Captain::BaseTaskService
|
||||
"#{endpoint}/v1"
|
||||
end
|
||||
|
||||
def make_api_call(model:, messages:, tools: [])
|
||||
def make_api_call(model:, messages:, schema: nil, tools: [])
|
||||
# Community edition prerequisite checks
|
||||
# Enterprise module handles these with more specific error messages (cloud vs self-hosted)
|
||||
return { error: I18n.t('captain.disabled'), error_code: 403 } unless captain_tasks_enabled?
|
||||
@@ -46,7 +46,7 @@ class Captain::BaseTaskService
|
||||
instrumentation_method = tools.any? ? :instrument_tool_session : :instrument_llm_call
|
||||
|
||||
response = send(instrumentation_method, instrumentation_params) do
|
||||
execute_ruby_llm_request(model: model, messages: messages, tools: tools)
|
||||
execute_ruby_llm_request(model: model, messages: messages, schema: schema, tools: tools)
|
||||
end
|
||||
|
||||
return response unless build_follow_up_context? && response[:message].present?
|
||||
@@ -54,9 +54,9 @@ class Captain::BaseTaskService
|
||||
response.merge(follow_up_context: build_follow_up_context(messages, response))
|
||||
end
|
||||
|
||||
def execute_ruby_llm_request(model:, messages:, tools: [])
|
||||
def execute_ruby_llm_request(model:, messages:, schema: nil, tools: [])
|
||||
Llm::Config.with_api_key(api_key, api_base: api_base) do |context|
|
||||
chat = build_chat(context, model: model, messages: messages, tools: tools)
|
||||
chat = build_chat(context, model: model, messages: messages, schema: schema, tools: tools)
|
||||
|
||||
conversation_messages = messages.reject { |m| m[:role] == 'system' }
|
||||
return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if conversation_messages.empty?
|
||||
@@ -69,10 +69,11 @@ class Captain::BaseTaskService
|
||||
{ error: e.message, request_messages: messages }
|
||||
end
|
||||
|
||||
def build_chat(context, model:, messages:, tools: [])
|
||||
def build_chat(context, model:, messages:, schema: nil, tools: [])
|
||||
chat = context.chat(model: model)
|
||||
system_msg = messages.find { |m| m[:role] == 'system' }
|
||||
chat.with_instructions(system_msg[:content]) if system_msg
|
||||
chat.with_schema(schema) if schema
|
||||
|
||||
if tools.any?
|
||||
tools.each { |tool| chat = chat.with_tool(tool) }
|
||||
@@ -131,7 +132,8 @@ class Captain::BaseTaskService
|
||||
.reorder('id desc')
|
||||
.each do |message|
|
||||
content = message.content_for_llm
|
||||
break unless content.present? && character_count + content.length <= TOKEN_LIMIT
|
||||
next if content.blank?
|
||||
break if character_count + content.length > TOKEN_LIMIT
|
||||
|
||||
messages.prepend({ role: (message.incoming? ? 'user' : 'assistant'), content: content })
|
||||
character_count += content.length
|
||||
|
||||
@@ -4,7 +4,6 @@ module Current
|
||||
thread_mattr_accessor :account_user
|
||||
thread_mattr_accessor :executed_by
|
||||
thread_mattr_accessor :contact
|
||||
thread_mattr_accessor :captain_resolve_reason
|
||||
|
||||
def self.reset
|
||||
Current.user = nil
|
||||
@@ -12,6 +11,5 @@ module Current
|
||||
Current.account_user = nil
|
||||
Current.executed_by = nil
|
||||
Current.contact = nil
|
||||
Current.captain_resolve_reason = nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,6 +21,8 @@ module Events::Types
|
||||
# FIXME: deprecate the opened and resolved events in future in favor of status changed event.
|
||||
CONVERSATION_OPENED = 'conversation.opened'
|
||||
CONVERSATION_RESOLVED = 'conversation.resolved'
|
||||
CONVERSATION_CAPTAIN_INFERENCE_RESOLVED = 'conversation.captain_inference_resolved'
|
||||
CONVERSATION_CAPTAIN_INFERENCE_HANDOFF = 'conversation.captain_inference_handoff'
|
||||
|
||||
CONVERSATION_STATUS_CHANGED = 'conversation.status_changed'
|
||||
CONVERSATION_CONTACT_CHANGED = 'conversation.contact_changed'
|
||||
|
||||
@@ -14,4 +14,8 @@ class GlobalConfigService
|
||||
GlobalConfig.clear_cache
|
||||
i.value
|
||||
end
|
||||
|
||||
def self.account_signup_enabled?
|
||||
load('ENABLE_ACCOUNT_SIGNUP', 'false').to_s != 'false'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
class Integrations::Linear::AccessTokenService
|
||||
TOKEN_URL = 'https://api.linear.app/oauth/token'.freeze
|
||||
MIGRATE_OLD_TOKEN_URL = 'https://api.linear.app/oauth/migrate_old_token'.freeze
|
||||
TOKEN_EXPIRY_BUFFER = 1.minute
|
||||
|
||||
pattr_initialize [:hook!]
|
||||
|
||||
def access_token
|
||||
return hook.access_token if token_valid?
|
||||
return refresh_access_token if refresh_token.present?
|
||||
return migrate_legacy_token if migration_applicable?
|
||||
|
||||
hook.access_token
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def refresh_access_token
|
||||
response = HTTParty.post(
|
||||
TOKEN_URL,
|
||||
headers: url_encoded_headers,
|
||||
body: {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refresh_token,
|
||||
client_id: client_id,
|
||||
client_secret: client_secret
|
||||
}
|
||||
)
|
||||
|
||||
return fallback_access_token unless response.success?
|
||||
|
||||
persist_tokens(response.parsed_response)
|
||||
hook.access_token
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Linear token refresh failed for hook #{hook.id}: #{e.message}")
|
||||
fallback_access_token
|
||||
end
|
||||
|
||||
def migrate_legacy_token
|
||||
response = HTTParty.post(
|
||||
MIGRATE_OLD_TOKEN_URL,
|
||||
headers: url_encoded_headers,
|
||||
body: {
|
||||
access_token: hook.access_token,
|
||||
client_id: client_id,
|
||||
client_secret: client_secret
|
||||
}
|
||||
)
|
||||
|
||||
return fallback_access_token unless response.success?
|
||||
|
||||
persist_tokens(response.parsed_response)
|
||||
hook.access_token
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Linear legacy token migration failed for hook #{hook.id}: #{e.message}")
|
||||
fallback_access_token
|
||||
end
|
||||
|
||||
def persist_tokens(token_data)
|
||||
raise ArgumentError, 'Missing access token in Linear token response' if token_data['access_token'].blank?
|
||||
|
||||
current_settings = hook_settings
|
||||
updated_settings = current_settings.merge(
|
||||
token_type: token_data['token_type'] || current_settings[:token_type],
|
||||
expires_in: token_data['expires_in'] || current_settings[:expires_in],
|
||||
expires_on: expires_on(token_data['expires_in']),
|
||||
scope: token_data['scope'] || current_settings[:scope],
|
||||
refresh_token: token_data['refresh_token'] || current_settings[:refresh_token]
|
||||
).compact
|
||||
|
||||
hook.update!(
|
||||
access_token: token_data['access_token'],
|
||||
settings: updated_settings
|
||||
)
|
||||
end
|
||||
|
||||
def token_valid?
|
||||
expiry = hook_settings[:expires_on]
|
||||
return false if expiry.blank?
|
||||
|
||||
Time.zone.parse(expiry).utc > (Time.current.utc + TOKEN_EXPIRY_BUFFER)
|
||||
rescue StandardError
|
||||
false
|
||||
end
|
||||
|
||||
def migration_applicable?
|
||||
hook_settings[:token_type].present?
|
||||
end
|
||||
|
||||
def refresh_token
|
||||
hook_settings[:refresh_token]
|
||||
end
|
||||
|
||||
def hook_settings
|
||||
hook.settings.to_h.with_indifferent_access
|
||||
end
|
||||
|
||||
def expires_on(expires_in)
|
||||
return hook_settings[:expires_on] if expires_in.blank?
|
||||
|
||||
(Time.current.utc + expires_in.to_i.seconds).to_s
|
||||
end
|
||||
|
||||
def url_encoded_headers
|
||||
{ 'Content-Type' => 'application/x-www-form-urlencoded' }
|
||||
end
|
||||
|
||||
def client_id
|
||||
GlobalConfigService.load('LINEAR_CLIENT_ID', nil)
|
||||
end
|
||||
|
||||
def client_secret
|
||||
GlobalConfigService.load('LINEAR_CLIENT_SECRET', nil)
|
||||
end
|
||||
|
||||
def fallback_access_token
|
||||
hook.reload.access_token
|
||||
rescue StandardError
|
||||
hook.access_token
|
||||
end
|
||||
end
|
||||
@@ -77,6 +77,10 @@ class Integrations::Linear::ProcessorService
|
||||
end
|
||||
|
||||
def linear_client
|
||||
@linear_client ||= Linear.new(linear_hook.access_token)
|
||||
@linear_client ||= Linear.new(linear_access_token)
|
||||
end
|
||||
|
||||
def linear_access_token
|
||||
@linear_access_token ||= Integrations::Linear::AccessTokenService.new(hook: linear_hook).access_token
|
||||
end
|
||||
end
|
||||
|
||||
@@ -66,7 +66,7 @@ module Integrations::LlmInstrumentationCompletionHelpers
|
||||
return if message.blank?
|
||||
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant')
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message)
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message.is_a?(String) ? message : message.to_json)
|
||||
end
|
||||
|
||||
def set_usage_metrics(span, result)
|
||||
|
||||
+7
-2
@@ -3,8 +3,9 @@ class Linear
|
||||
REVOKE_URL = 'https://api.linear.app/oauth/revoke'.freeze
|
||||
PRIORITY_LEVELS = (0..4).to_a
|
||||
|
||||
def initialize(access_token)
|
||||
def initialize(access_token, refresh_token: nil)
|
||||
@access_token = access_token
|
||||
@refresh_token = refresh_token
|
||||
raise ArgumentError, 'Missing Credentials' if access_token.blank?
|
||||
end
|
||||
|
||||
@@ -79,9 +80,13 @@ class Linear
|
||||
end
|
||||
|
||||
def revoke_token
|
||||
token = @refresh_token.presence || @access_token
|
||||
token_type_hint = @refresh_token.present? ? 'refresh_token' : 'access_token'
|
||||
|
||||
response = HTTParty.post(
|
||||
REVOKE_URL,
|
||||
headers: { 'Authorization' => "Bearer #{@access_token}", 'Content-Type' => 'application/json' }
|
||||
headers: { 'Content-Type' => 'application/x-www-form-urlencoded' },
|
||||
body: { token: token, token_type_hint: token_type_hint }
|
||||
)
|
||||
response.success?
|
||||
end
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class OnlineStatusTracker
|
||||
# NOTE: You can customise the environment variable to keep your agents/contacts as online for longer
|
||||
PRESENCE_DURATION = ENV.fetch('PRESENCE_DURATION', 20).to_i.seconds
|
||||
# Widget pings every 60s, so contacts need a longer presence window
|
||||
CONTACT_PRESENCE_DURATION = ENV.fetch('CONTACT_PRESENCE_DURATION', 90).to_i.seconds
|
||||
|
||||
# presence : sorted set with timestamp as the score & object id as value
|
||||
|
||||
@@ -11,7 +13,8 @@ class OnlineStatusTracker
|
||||
|
||||
def self.get_presence(account_id, obj_type, obj_id)
|
||||
connected_time = ::Redis::Alfred.zscore(presence_key(account_id, obj_type), obj_id)
|
||||
connected_time && connected_time > (Time.zone.now - PRESENCE_DURATION).to_i
|
||||
duration = obj_type == 'Contact' ? CONTACT_PRESENCE_DURATION : PRESENCE_DURATION
|
||||
connected_time && connected_time > (Time.zone.now - duration).to_i
|
||||
end
|
||||
|
||||
def self.presence_key(account_id, type)
|
||||
@@ -39,7 +42,7 @@ class OnlineStatusTracker
|
||||
end
|
||||
|
||||
def self.get_available_contact_ids(account_id)
|
||||
range_start = (Time.zone.now - PRESENCE_DURATION).to_i
|
||||
range_start = (Time.zone.now - CONTACT_PRESENCE_DURATION).to_i
|
||||
# exclusive minimum score is specified by prefixing (
|
||||
# we are clearing old records because this could clogg up the sorted set
|
||||
::Redis::Alfred.zremrangebyscore(presence_key(account_id, 'Contact'), '-inf', "(#{range_start}")
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Migrate max_assignment_limit to Agent Capacity Policies
|
||||
#
|
||||
# Converts legacy per-inbox max_assignment_limit settings into
|
||||
# AgentCapacityPolicy records used by Assignment V2.
|
||||
#
|
||||
# Usage Examples:
|
||||
# # Migrate a single account
|
||||
# ACCOUNT_ID=1 bundle exec rake assignment_v2:migrate
|
||||
#
|
||||
# # Migrate all accounts in the installation
|
||||
# bundle exec rake assignment_v2:migrate
|
||||
#
|
||||
# Parameters:
|
||||
# ACCOUNT_ID: (optional) ID of the account to migrate. If omitted, migrates all accounts.
|
||||
#
|
||||
# rubocop:disable Metrics/BlockLength
|
||||
namespace :assignment_v2 do
|
||||
desc 'Migrate max_assignment_limit inbox settings to agent capacity policies'
|
||||
task migrate: :environment do
|
||||
int_max = (2**31) - 1
|
||||
policy_name = 'Auto Assignment Capacity'
|
||||
|
||||
account_id = ENV.fetch('ACCOUNT_ID', nil)
|
||||
accounts = account_id.present? ? Account.where(id: account_id) : Account.all
|
||||
|
||||
if account_id.blank?
|
||||
print 'No ACCOUNT_ID specified. This will migrate ALL accounts. Continue? [y/N] '
|
||||
abort 'Aborted.' unless $stdin.gets.chomp.casecmp('y').zero?
|
||||
end
|
||||
|
||||
if account_id.present? && accounts.empty?
|
||||
puts "Error: Account with ID #{account_id} not found"
|
||||
exit(1)
|
||||
end
|
||||
|
||||
total = accounts.count
|
||||
puts "Migrating assignment policies for #{total} account(s)..."
|
||||
puts "Started at: #{Time.current}"
|
||||
|
||||
migrated = 0
|
||||
skipped = 0
|
||||
errored = 0
|
||||
|
||||
accounts.find_each do |account|
|
||||
inboxes_with_limit = account.inboxes.where("auto_assignment_config->>'max_assignment_limit' ~ '[1-9]'")
|
||||
|
||||
if inboxes_with_limit.empty?
|
||||
skipped += 1
|
||||
puts " [#{migrated + skipped + errored}/#{total}] Account #{account.id} — skipped (no inboxes with limit)"
|
||||
next
|
||||
end
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
policy = AgentCapacityPolicy.find_or_create_by!(account: account, name: policy_name) do |p|
|
||||
p.description = 'Migrated from inbox settings'
|
||||
end
|
||||
|
||||
inboxes_with_limit.each do |inbox|
|
||||
next if InboxCapacityLimit.exists?(agent_capacity_policy_id: policy.id, inbox_id: inbox.id)
|
||||
|
||||
limit = [inbox.auto_assignment_config['max_assignment_limit'].to_i, int_max].min
|
||||
InboxCapacityLimit.create!(agent_capacity_policy: policy, inbox: inbox, conversation_limit: limit)
|
||||
end
|
||||
|
||||
member_user_ids = InboxMember.where(inbox_id: inboxes_with_limit.select(:id)).distinct.pluck(:user_id)
|
||||
account.account_users
|
||||
.where(user_id: member_user_ids, agent_capacity_policy_id: nil)
|
||||
.find_each { |au| au.update!(agent_capacity_policy_id: policy.id) }
|
||||
end
|
||||
|
||||
migrated += 1
|
||||
puts " [#{migrated + skipped + errored}/#{total}] Account #{account.id} — migrated"
|
||||
rescue StandardError => e
|
||||
errored += 1
|
||||
puts " [#{migrated + skipped + errored}/#{total}] Account #{account.id} — error: #{e.message}"
|
||||
end
|
||||
|
||||
puts "\nDone! Migrated: #{migrated}, Skipped: #{skipped}, Errored: #{errored}, Total: #{total}"
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/BlockLength
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace :companies do
|
||||
desc 'Backfill companies from existing contact email domains'
|
||||
task backfill: :environment do
|
||||
puts 'Starting company backfill migration...'
|
||||
puts 'This will process all accounts and create companies from contact email domains.'
|
||||
puts 'The job will run in the background via Sidekiq'
|
||||
puts ''
|
||||
Migration::CompanyBackfillJob.perform_later
|
||||
puts 'Company backfill job has been enqueued.'
|
||||
puts 'Monitor progress in logs or Sidekiq dashboard.'
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user