feat(captain): group FAQ suggestions by language
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user