feat(captain): group FAQ suggestions by language

This commit is contained in:
aakashb95
2026-07-14 14:10:17 +05:30
parent f6f688acbb
commit c272de5be9
2 changed files with 130 additions and 21 deletions
@@ -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)
@@ -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