fix(captain): serialize FAQ suggestion grouping

This commit is contained in:
aakashb95
2026-07-22 13:14:48 +05:30
parent e48d612a55
commit 9a780e8716
4 changed files with 63 additions and 8 deletions
@@ -1,6 +1,10 @@
class Captain::Llm::ConversationFaqJob < ApplicationJob
class Captain::Llm::ConversationFaqJob < MutexApplicationJob
queue_as :low
LOCK_TIMEOUT = 10.minutes
retry_on_lock_conflict wait: 30.seconds, attempts: 30
def perform(conversation, assistant)
inbox = conversation.inbox
@@ -9,6 +13,18 @@ class Captain::Llm::ConversationFaqJob < ApplicationJob
return if assistant.config['feature_faq'].blank?
Captain::Llm::ConversationFaqService.new(assistant, conversation).generate_suggestions
with_lock(lock_key(assistant, conversation), LOCK_TIMEOUT) do
Captain::Llm::ConversationFaqService.new(assistant, conversation).generate_suggestions
end
end
private
def lock_key(assistant, conversation)
format(
::Redis::Alfred::CAPTAIN_CONVERSATION_FAQ_MUTEX,
assistant_id: assistant.id,
language: Captain::Llm::ConversationFaqService.language_for(conversation)
)
end
end
@@ -6,6 +6,20 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService
LLM_FEATURE = 'conversation_faq_generation'.freeze
FAQ_MATCH_MODEL = 'gpt-4.1-mini'.freeze
def self.language_for(conversation)
language = conversation.language.presence || conversation.account.locale.presence || I18n.default_locale.to_s
normalize_language(language)
end
def self.normalize_language(language)
language.to_s.tr('-', '_').split('_').first.downcase
end
def self.account_language_for(account)
normalize_language(account.locale.presence || I18n.default_locale.to_s)
end
private_class_method :normalize_language
def initialize(assistant, conversation)
super(feature: LLM_FEATURE, account: conversation.account, fallback_model: Llm::Models.default_model_for(LLM_FEATURE))
@assistant = assistant
@@ -180,15 +194,11 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService
end
def faq_language
@faq_language ||= normalize_language(conversation.language.presence || conversation.account.locale.presence || I18n.default_locale.to_s)
@faq_language ||= self.class.language_for(conversation)
end
def account_language
@account_language ||= normalize_language(conversation.account.locale.presence || I18n.default_locale.to_s)
end
def normalize_language(language)
language.to_s.tr('-', '_').split('_').first.downcase
@account_language ||= self.class.account_language_for(conversation.account)
end
def language_name(language)
+1
View File
@@ -89,6 +89,7 @@ module Redis::RedisKeys
WHATSAPP_MESSAGE_MUTEX = 'WHATSAPP_MESSAGE_CREATE_LOCK::%<inbox_id>s::%<sender_id>s'.freeze
CRM_PROCESS_MUTEX = 'CRM_PROCESS_MUTEX::%<hook_id>s'.freeze
CAPTAIN_DOCUMENT_SYNC_MUTEX = 'CAPTAIN_DOCUMENT_SYNC_LOCK::%<document_id>s'.freeze
CAPTAIN_CONVERSATION_FAQ_MUTEX = 'CAPTAIN_CONVERSATION_FAQ_LOCK::%<assistant_id>s::%<language>s'.freeze
## Auto Assignment Keys
# Track conversation assignments to agents for rate limiting
@@ -6,10 +6,14 @@ RSpec.describe Captain::Llm::ConversationFaqJob, type: :job do
let(:assistant) { create(:captain_assistant, account: account, config: { feature_faq: true }) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, first_reply_created_at: Time.zone.now) }
let(:faq_service) { instance_double(Captain::Llm::ConversationFaqService, generate_suggestions: []) }
let(:lock_manager) { instance_double(Redis::LockManager, lock: true, unlock: true) }
let(:lock_key) { "CAPTAIN_CONVERSATION_FAQ_LOCK::#{assistant.id}::en" }
before do
create(:captain_inbox, inbox: inbox, captain_assistant: assistant)
conversation.update!(status: :resolved)
allow(Redis::LockManager).to receive(:new).and_return(lock_manager)
allow(Captain::Llm::ConversationFaqService).to receive(:new).and_return(faq_service)
end
describe '#perform' do
@@ -25,5 +29,29 @@ RSpec.describe Captain::Llm::ConversationFaqJob, type: :job do
described_class.perform_now(conversation, assistant)
end
it 'locks FAQ grouping for the assistant and normalized language' do
conversation.update!(additional_attributes: { conversation_language: 'pt-BR' })
expected_key = "CAPTAIN_CONVERSATION_FAQ_LOCK::#{assistant.id}::pt"
expect(lock_manager).to receive(:lock).with(expected_key, described_class::LOCK_TIMEOUT).and_return(true)
expect(lock_manager).to receive(:unlock).with(expected_key)
described_class.perform_now(conversation, assistant)
end
context 'when another job holds the grouping lock' do
before do
allow(lock_manager).to receive(:lock).with(lock_key, described_class::LOCK_TIMEOUT).and_return(false)
end
it 'does not generate suggestions concurrently' do
expect(Captain::Llm::ConversationFaqService).not_to receive(:new)
expect do
described_class.new.perform(conversation, assistant)
end.to raise_error(MutexApplicationJob::LockAcquisitionError)
end
end
end
end