From 3a4249da11f9dd5c41580a2b05a6bdf4359ab8ed Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 20 Mar 2025 06:55:33 +0530 Subject: [PATCH] feat: Add support for multi-language support for Captain (#11068) This PR implements the following features - FAQs from conversations will be generated in account language - Contact notes will be generated in account language - Copilot chat will respond in user language, unless the agent asks the question in a different language ## Changes ### Copilot Chat - Update the prompt to include an instruction for the language, the bot will reply in asked language, but will default to account language - Update the `ChatService` class to include pass the language to `SystemPromptsService` ### FAQ and Contact note generation - Update contact note generator and conversation generator to include account locale - Pass the account locale to `SystemPromptsService`
Screenshots #### FAQs being generated in system langauge ![CleanShot 2025-03-12 at 13 32 30@2x](https://github.com/user-attachments/assets/84685bd8-3785-4432-aff3-419f60d96dd3) #### Copilot responding in system language ![CleanShot 2025-03-12 at 13 47 03@2x](https://github.com/user-attachments/assets/38383293-4228-47bd-b74a-773e9a194f90)
--------- Co-authored-by: Muhsin Keloth Co-authored-by: Pranav --- Gemfile | 1 + Gemfile.lock | 3 + app/models/account.rb | 8 +++ app/models/concerns/assignment_handler.rb | 4 ++ app/models/conversation.rb | 8 +-- .../v1/accounts/conversations_controller.rb | 3 +- .../services/captain/copilot/chat_service.rb | 3 +- .../captain/llm/contact_notes_service.rb | 4 +- .../captain/llm/conversation_faq_service.rb | 4 +- .../captain/llm/system_prompts_service.rb | 3 +- .../captain/copilot/chat_service_spec.rb | 62 +++++++++++++++++-- .../llm/conversation_faq_service_spec.rb | 20 ++++++ spec/models/account_spec.rb | 26 ++++++++ 13 files changed, 136 insertions(+), 13 deletions(-) diff --git a/Gemfile b/Gemfile index b94522513..1e8605379 100644 --- a/Gemfile +++ b/Gemfile @@ -173,6 +173,7 @@ gem 'pgvector' # Convert Website HTML to Markdown gem 'reverse_markdown' +gem 'iso-639' gem 'ruby-openai' gem 'shopify_api' diff --git a/Gemfile.lock b/Gemfile.lock index 07aa2d638..bfe4b1970 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -378,6 +378,8 @@ GEM io-console (0.6.0) irb (1.7.2) reline (>= 0.3.6) + iso-639 (0.3.8) + csv jbuilder (2.11.5) actionview (>= 5.0.0) activesupport (>= 5.0.0) @@ -913,6 +915,7 @@ DEPENDENCIES hashie html2text image_processing + iso-639 jbuilder json_refs json_schemer diff --git a/app/models/account.rb b/app/models/account.rb index a35affb3e..e6216873c 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -128,6 +128,14 @@ class Account < ApplicationRecord } end + def locale_english_name + # the locale can also be something like pt_BR, en_US, fr_FR, etc. + # the format is `_` + # we need to extract the language code from the locale + account_locale = locale&.split('_')&.first + ISO_639.find(account_locale)&.english_name&.downcase || 'english' + end + private def notify_creation diff --git a/app/models/concerns/assignment_handler.rb b/app/models/concerns/assignment_handler.rb index 5dc89f779..a9f529f65 100644 --- a/app/models/concerns/assignment_handler.rb +++ b/app/models/concerns/assignment_handler.rb @@ -48,4 +48,8 @@ module AssignmentHandler create_assignee_change_activity(user_name) end end + + def self_assign?(assignee_id) + assignee_id.present? && Current.user&.id == assignee_id + end end diff --git a/app/models/conversation.rb b/app/models/conversation.rb index ba60f5295..2a7a49223 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -125,6 +125,10 @@ class Conversation < ApplicationRecord last_message_in_messaging_window?(messaging_window) end + def language + additional_attributes&.dig('conversation_language') + end + def last_activity_at self[:last_activity_at] || created_at end @@ -257,10 +261,6 @@ class Conversation < ApplicationRecord ) end - def self_assign?(assignee_id) - assignee_id.present? && Current.user&.id == assignee_id - end - def load_attributes_created_by_db_triggers # Display id is set via a trigger in the database # So we need to specifically fetch it after the record is created diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb index a9dcb0b67..c382206b6 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb @@ -20,7 +20,8 @@ module Enterprise::Api::V1::Accounts::ConversationsController response = Captain::Copilot::ChatService.new( assistant, previous_messages: copilot_params[:previous_messages], - conversation_history: @conversation.to_llm_text + conversation_history: @conversation.to_llm_text, + language: @conversation.account.locale_english_name ).generate_response(copilot_params[:message]) render json: { message: response['response'] } diff --git a/enterprise/app/services/captain/copilot/chat_service.rb b/enterprise/app/services/captain/copilot/chat_service.rb index 3d53d7b5c..6fd3c4e68 100644 --- a/enterprise/app/services/captain/copilot/chat_service.rb +++ b/enterprise/app/services/captain/copilot/chat_service.rb @@ -9,6 +9,7 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService @assistant = assistant @conversation_history = config[:conversation_history] @previous_messages = config[:previous_messages] || [] + @language = config[:language] || 'english' @messages = [system_message, conversation_history_context] + @previous_messages @response = '' end @@ -27,7 +28,7 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService def system_message { role: 'system', - content: Captain::Llm::SystemPromptsService.copilot_response_generator(@assistant.config['product_name']) + content: Captain::Llm::SystemPromptsService.copilot_response_generator(@assistant.config['product_name'], @language) } end diff --git a/enterprise/app/services/captain/llm/contact_notes_service.rb b/enterprise/app/services/captain/llm/contact_notes_service.rb index 75aee412f..480225a03 100644 --- a/enterprise/app/services/captain/llm/contact_notes_service.rb +++ b/enterprise/app/services/captain/llm/contact_notes_service.rb @@ -26,7 +26,9 @@ class Captain::Llm::ContactNotesService < Llm::BaseOpenAiService end def chat_parameters - prompt = Captain::Llm::SystemPromptsService.notes_generator + account_language = @conversation.account.locale_english_name + prompt = Captain::Llm::SystemPromptsService.notes_generator(account_language) + { model: @model, response_format: { type: 'json_object' }, diff --git a/enterprise/app/services/captain/llm/conversation_faq_service.rb b/enterprise/app/services/captain/llm/conversation_faq_service.rb index 682818f73..077791a2c 100644 --- a/enterprise/app/services/captain/llm/conversation_faq_service.rb +++ b/enterprise/app/services/captain/llm/conversation_faq_service.rb @@ -89,7 +89,9 @@ class Captain::Llm::ConversationFaqService < Llm::BaseOpenAiService end def chat_parameters - prompt = Captain::Llm::SystemPromptsService.conversation_faq_generator + account_language = @conversation.account.locale_english_name + prompt = Captain::Llm::SystemPromptsService.conversation_faq_generator(account_language) + { model: @model, response_format: { type: 'json_object' }, diff --git a/enterprise/app/services/captain/llm/system_prompts_service.rb b/enterprise/app/services/captain/llm/system_prompts_service.rb index 3007a58bf..8a8753f60 100644 --- a/enterprise/app/services/captain/llm/system_prompts_service.rb +++ b/enterprise/app/services/captain/llm/system_prompts_service.rb @@ -56,7 +56,7 @@ class Captain::Llm::SystemPromptsService SYSTEM_PROMPT_MESSAGE end - def copilot_response_generator(product_name) + def copilot_response_generator(product_name, language) <<~SYSTEM_PROMPT_MESSAGE [Identity] You are Captain, a helpful and friendly copilot assistant for support agents using the product #{product_name}. Your primary role is to assist support agents by retrieving information, compiling accurate responses, and guiding them through customer interactions. @@ -67,6 +67,7 @@ class Captain::Llm::SystemPromptsService [Response Guidelines] - Use natural, polite, and conversational language that is clear and easy to follow. Keep sentences short and use simple words. + - Reply in the language the agent is using, if you're not able to detect the language, reply in #{language}. - Provide brief and relevant responses—typically one or two sentences unless a more detailed explanation is necessary. - Do not use your own training data or assumptions to answer queries. Base responses strictly on the provided information. - If the query is unclear, ask concise clarifying questions instead of making assumptions. diff --git a/spec/enterprise/services/captain/copilot/chat_service_spec.rb b/spec/enterprise/services/captain/copilot/chat_service_spec.rb index c4b759c85..5daabd5bf 100644 --- a/spec/enterprise/services/captain/copilot/chat_service_spec.rb +++ b/spec/enterprise/services/captain/copilot/chat_service_spec.rb @@ -2,17 +2,71 @@ require 'rails_helper' RSpec.describe Captain::Copilot::ChatService do let(:account) { create(:account, custom_attributes: { plan_name: 'startups' }) } - let(:inbox) { create(:inbox, account: account) } - let(:assistant) { create(:captain_assistant, account: account) } let(:captain_inbox_association) { create(:captain_inbox, captain_assistant: assistant, inbox: inbox) } - let(:mock_captain_agent) { instance_double(Captain::Agent) } let(:mock_captain_tool) { instance_double(Captain::Tool) } let(:mock_openai_client) { instance_double(OpenAI::Client) } + let(:inbox) { create(:inbox, account: account) } + let(:assistant) { create(:captain_assistant, account: account) } + + before do + create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') + end + + describe '#initialize' do + it 'sets default language to english when not specified' do + service = described_class.new(assistant, { previous_messages: [], conversation_history: '' }) + expect(service.instance_variable_get(:@language)).to eq('english') + end + + it 'uses the specified language when provided' do + service = described_class.new(assistant, { + previous_messages: [], + conversation_history: '', + language: 'spanish' + }) + expect(service.instance_variable_get(:@language)).to eq('spanish') + end + end + + describe '#generate_response' do + before do + allow(OpenAI::Client).to receive(:new).and_return(mock_openai_client) + allow(mock_openai_client).to receive(:chat).and_return({ choices: [{ message: { content: '{ "result": "Hey" }' } }] }.with_indifferent_access) + + allow(Captain::Agent).to receive(:new).and_return(mock_captain_agent) + allow(mock_captain_agent).to receive(:execute).and_return(true) + allow(mock_captain_agent).to receive(:register_tool).and_return(true) + + allow(Captain::Tool).to receive(:new).and_return(mock_captain_tool) + allow(mock_captain_tool).to receive(:register_method).and_return(true) + + allow(account).to receive(:increment_response_usage).and_return(true) + end + + it 'increments usage' do + described_class.new(assistant, { previous_messages: ['Hello'], conversation_history: 'Hi' }).generate_response('Hey') + expect(account).to have_received(:increment_response_usage).once + end + + it 'includes language in system message' do + service = described_class.new(assistant, { + previous_messages: [], + conversation_history: '', + language: 'spanish' + }) + + allow(Captain::Llm::SystemPromptsService).to receive(:copilot_response_generator) + .with(assistant.config['product_name'], 'spanish') + .and_return('Spanish system prompt') + + system_message = service.send(:system_message) + expect(system_message[:content]).to eq('Spanish system prompt') + end + end describe '#execute' do before do - create(:installation_config) { create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') } allow(OpenAI::Client).to receive(:new).and_return(mock_openai_client) allow(mock_openai_client).to receive(:chat).and_return({ choices: [{ message: { content: '{ "result": "Hey" }' } }] }.with_indifferent_access) 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 fe0276b49..ee993da37 100644 --- a/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb +++ b/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb @@ -145,5 +145,25 @@ RSpec.describe Captain::Llm::ConversationFaqService do { role: 'user', content: conversation.to_llm_text } ) end + + context 'when conversation has different language' do + let(:account) { create(:account, locale: 'fr') } + let(:conversation) do + create(:conversation, account: account, + first_reply_created_at: Time.zone.now) + end + + it 'includes system prompt with correct language' do + allow(Captain::Llm::SystemPromptsService).to receive(:conversation_faq_generator) + .with('french') + .and_return('system prompt in french') + + params = service.send(:chat_parameters) + + expect(params[:messages]).to include( + { role: 'system', content: 'system prompt in french' } + ) + end + end end end diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index 4b200fb9a..e545f8e4a 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -108,4 +108,30 @@ RSpec.describe Account do expect(ActiveRecord::Base.connection.execute(query).count).to eq(0) end end + + describe 'locale' do + it 'returns correct language if the value is set' do + account = create(:account, locale: 'fr') + expect(account.locale).to eq('fr') + expect(account.locale_english_name).to eq('french') + end + + it 'returns english if the value is not set' do + account = create(:account, locale: nil) + expect(account.locale).to be_nil + expect(account.locale_english_name).to eq('english') + end + + it 'returns english if the value is empty string' do + account = create(:account, locale: '') + expect(account.locale).to be_nil + expect(account.locale_english_name).to eq('english') + end + + it 'returns correct language if the value has country code' do + account = create(:account, locale: 'pt_BR') + expect(account.locale).to eq('pt_BR') + expect(account.locale_english_name).to eq('portuguese') + end + end end