From c272de5be9a1b5de1aa2103ea528ef57cfc5b423 Mon Sep 17 00:00:00 2001 From: aakashb95 Date: Sat, 11 Jul 2026 21:41:29 +0530 Subject: [PATCH] feat(captain): group FAQ suggestions by language --- .../captain/llm/conversation_faq_service.rb | 60 +++++++++--- .../llm/conversation_faq_service_spec.rb | 91 +++++++++++++++++-- 2 files changed, 130 insertions(+), 21 deletions(-) diff --git a/enterprise/app/services/captain/llm/conversation_faq_service.rb b/enterprise/app/services/captain/llm/conversation_faq_service.rb index a9cc97158..de281dee6 100644 --- a/enterprise/app/services/captain/llm/conversation_faq_service.rb +++ b/enterprise/app/services/captain/llm/conversation_faq_service.rb @@ -74,20 +74,16 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService def route_candidate(faq) embedding = embedding_service.get_embedding(candidate_text(faq)) - if matching_record(assistant.responses.approved, faq, embedding) - return Captain::FaqObservation.create!( - conversation: conversation, - generated_question: faq.fetch('question'), - generated_answer: faq.fetch('answer'), - status: :discarded - ) + if matching_record(approved_faqs_for_language, faq, embedding) + return discard_observation(faq) end - suggestion = matching_record(assistant.faq_suggestions.open, faq, embedding) + suggestion = matching_record(open_suggestions_for_language, faq, embedding) suggestion ||= assistant.faq_suggestions.create!( question: faq.fetch('question'), answer: faq.fetch('answer'), - embedding: embedding + embedding: embedding, + language: faq_language ) attach_observation(suggestion, faq) @@ -137,13 +133,34 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService conversation: conversation, generated_question: faq.fetch('question'), generated_answer: faq.fetch('answer'), + language: faq_language, status: :attached ) - suggestion.update!(source_count: suggestion.source_count + 1) + suggestion.update!(source_count: suggestion.observations.attached.count) observation end end + def discard_observation(faq) + Captain::FaqObservation.find_or_create_by!( + conversation: conversation, + generated_question: faq.fetch('question'), + generated_answer: faq.fetch('answer'), + language: faq_language, + status: :discarded + ) + end + + def open_suggestions_for_language + assistant.faq_suggestions.open.by_language(faq_language) + end + + def approved_faqs_for_language + return assistant.responses.approved if faq_language == account_language + + assistant.responses.none + end + def candidate_text(faq) "#{faq.fetch('question')}: #{faq.fetch('answer')}" end @@ -173,7 +190,7 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService { role: 'system', content: system_prompt }, { role: 'user', content: content } ], - metadata: { assistant_id: assistant.id } + metadata: { assistant_id: assistant.id, language: faq_language } } end @@ -189,13 +206,28 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService { role: 'system', content: prompt }, { role: 'user', content: comparison.to_json } ], - metadata: { assistant_id: assistant.id } + metadata: { assistant_id: assistant.id, language: faq_language } } end def system_prompt - account_language = conversation.account.locale_english_name - Captain::Llm::ConversationFaqPromptsService.generator(account_language) + Captain::Llm::ConversationFaqPromptsService.generator(language_name(faq_language)) + end + + def faq_language + @faq_language ||= normalize_language(conversation.language.presence || conversation.account.locale.presence || I18n.default_locale.to_s) + 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('-', '_') + end + + def language_name(language) + ISO_639.find(language.split('_').first)&.english_name&.downcase || 'english' end def parse_generation_response(response) diff --git a/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb b/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb index ba0de3d08..b4302447f 100644 --- a/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb +++ b/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb @@ -148,13 +148,13 @@ RSpec.describe Captain::Llm::ConversationFaqService do it 'saves open suggestions with one attached source each' do service.generate_and_deduplicate expect( - captain_assistant.faq_suggestions.pluck(:question, :answer, :status, :source_count) + captain_assistant.faq_suggestions.pluck(:question, :answer, :status, :source_count, :language) ).to contain_exactly( - ['What is the purpose?', 'To help users.', 'open', 1], - ['How does it work?', 'Through AI.', 'open', 1] + ['What is the purpose?', 'To help users.', 'open', 1, 'en'], + ['How does it work?', 'Through AI.', 'open', 1, 'en'] ) - expect(Captain::FaqObservation.attached.pluck(:conversation_id)).to contain_exactly( - conversation.id, conversation.id + expect(Captain::FaqObservation.attached.pluck(:conversation_id, :language)).to contain_exactly( + [conversation.id, 'en'], [conversation.id, 'en'] ) end end @@ -198,8 +198,19 @@ RSpec.describe Captain::Llm::ConversationFaqService do context 'when an open suggestion is the same FAQ' do let(:sample_faqs) { [{ 'question' => 'How can I use the feature?', 'answer' => 'Enable it in settings.' }] } let(:existing_suggestion) do - captain_assistant.faq_suggestions.create!(question: 'How do I enable the feature?', answer: 'Turn it on in settings.', - embedding: embedding_one, source_count: 1) + captain_assistant.faq_suggestions.create!( + question: 'How do I enable the feature?', + answer: 'Turn it on in settings.', + embedding: embedding_one + ).tap do |suggestion| + suggestion.observations.create!( + conversation: create(:conversation, account: captain_assistant.account), + generated_question: suggestion.question, + generated_answer: suggestion.answer, + language: suggestion.language + ) + suggestion.update!(source_count: suggestion.observations.attached.count) + end end let(:equivalence_response) { instance_double(RubyLLM::Message, content: { same_faq: true }.to_json) } @@ -220,6 +231,50 @@ RSpec.describe Captain::Llm::ConversationFaqService do end end + context 'when a similar open suggestion uses another language' do + let(:sample_faqs) { [{ 'question' => 'Como ativo o recurso?', 'answer' => 'Ative nas configuracoes.' }] } + let!(:existing_suggestion) do + captain_assistant.faq_suggestions.create!(question: 'How do I enable the feature?', answer: 'Turn it on in settings.', + embedding: embedding_one, language: 'en', source_count: 1) + end + + before do + conversation.update!(additional_attributes: { conversation_language: 'pt-BR' }) + allow(embedding_service).to receive(:get_embedding).and_return(embedding_one) + end + + it 'creates a separate suggestion in the conversation language' do + expect do + service.generate_and_deduplicate + end.to change(captain_assistant.faq_suggestions, :count).by(1) + + expect(captain_assistant.faq_suggestions.pluck(:language)).to contain_exactly('en', 'pt_BR') + expect(existing_suggestion.reload.source_count).to eq(1) + end + end + + context 'when a similar approved FAQ uses the account language' do + let(:sample_faqs) { [{ 'question' => 'Como ativo o recurso?', 'answer' => 'Ative nas configuracoes.' }] } + let!(:existing_response) do + create(:captain_assistant_response, assistant: captain_assistant, account: captain_assistant.account, + question: 'How do I enable the feature?', answer: 'Turn it on in settings.', + embedding: embedding_one) + end + + before do + conversation.update!(additional_attributes: { conversation_language: 'pt-BR' }) + allow(embedding_service).to receive(:get_embedding).and_return(embedding_one) + end + + it 'does not discard a candidate in another language' do + expect do + service.generate_and_deduplicate + end.to change(captain_assistant.faq_suggestions, :count).by(1) + expect(Captain::FaqObservation.discarded.count).to be_zero + expect(captain_assistant.faq_suggestions.last.language).to eq('pt_BR') + end + end + context 'when LLM API fails' do before do allow(mock_chat).to receive(:ask).and_raise(RubyLLM::Error.new(nil, 'API Error')) @@ -283,5 +338,27 @@ RSpec.describe Captain::Llm::ConversationFaqService do service.generate_and_deduplicate end end + + context 'when conversation language differs from account language' do + let(:account) { create(:account, locale: 'en') } + let(:captain_assistant) { create(:captain_assistant, account: account) } + let(:conversation) do + create(:conversation, account: account, first_reply_created_at: Time.zone.now, + additional_attributes: { conversation_language: 'pt-BR' }) + end + + before do + allow(embedding_service).to receive(:get_embedding).and_return(embedding_one, embedding_two) + end + + it 'uses the conversation language for the system prompt' do + expect(Captain::Llm::ConversationFaqPromptsService).to receive(:generator) + .with('portuguese') + .at_least(:once) + .and_call_original + + service.generate_and_deduplicate + end + end end end