mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 02:57:57 +00:00 
			
		
		
		
	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` <details><summary>Screenshots</summary> #### FAQs being generated in system langauge  #### Copilot responding in system language  </details> --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
		
							
								
								
									
										1
									
								
								Gemfile
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								Gemfile
									
									
									
									
									
								
							| @@ -173,6 +173,7 @@ gem 'pgvector' | |||||||
| # Convert Website HTML to Markdown | # Convert Website HTML to Markdown | ||||||
| gem 'reverse_markdown' | gem 'reverse_markdown' | ||||||
|  |  | ||||||
|  | gem 'iso-639' | ||||||
| gem 'ruby-openai' | gem 'ruby-openai' | ||||||
|  |  | ||||||
| gem 'shopify_api' | gem 'shopify_api' | ||||||
|   | |||||||
| @@ -378,6 +378,8 @@ GEM | |||||||
|     io-console (0.6.0) |     io-console (0.6.0) | ||||||
|     irb (1.7.2) |     irb (1.7.2) | ||||||
|       reline (>= 0.3.6) |       reline (>= 0.3.6) | ||||||
|  |     iso-639 (0.3.8) | ||||||
|  |       csv | ||||||
|     jbuilder (2.11.5) |     jbuilder (2.11.5) | ||||||
|       actionview (>= 5.0.0) |       actionview (>= 5.0.0) | ||||||
|       activesupport (>= 5.0.0) |       activesupport (>= 5.0.0) | ||||||
| @@ -913,6 +915,7 @@ DEPENDENCIES | |||||||
|   hashie |   hashie | ||||||
|   html2text |   html2text | ||||||
|   image_processing |   image_processing | ||||||
|  |   iso-639 | ||||||
|   jbuilder |   jbuilder | ||||||
|   json_refs |   json_refs | ||||||
|   json_schemer |   json_schemer | ||||||
|   | |||||||
| @@ -128,6 +128,14 @@ class Account < ApplicationRecord | |||||||
|     } |     } | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def locale_english_name | ||||||
|  |     # the locale can also be something like pt_BR, en_US, fr_FR, etc. | ||||||
|  |     # the format is `<locale_code>_<country_code>` | ||||||
|  |     # 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 |   private | ||||||
|  |  | ||||||
|   def notify_creation |   def notify_creation | ||||||
|   | |||||||
| @@ -48,4 +48,8 @@ module AssignmentHandler | |||||||
|       create_assignee_change_activity(user_name) |       create_assignee_change_activity(user_name) | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def self_assign?(assignee_id) | ||||||
|  |     assignee_id.present? && Current.user&.id == assignee_id | ||||||
|  |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -125,6 +125,10 @@ class Conversation < ApplicationRecord | |||||||
|     last_message_in_messaging_window?(messaging_window) |     last_message_in_messaging_window?(messaging_window) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def language | ||||||
|  |     additional_attributes&.dig('conversation_language') | ||||||
|  |   end | ||||||
|  |  | ||||||
|   def last_activity_at |   def last_activity_at | ||||||
|     self[:last_activity_at] || created_at |     self[:last_activity_at] || created_at | ||||||
|   end |   end | ||||||
| @@ -257,10 +261,6 @@ class Conversation < ApplicationRecord | |||||||
|     ) |     ) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def self_assign?(assignee_id) |  | ||||||
|     assignee_id.present? && Current.user&.id == assignee_id |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   def load_attributes_created_by_db_triggers |   def load_attributes_created_by_db_triggers | ||||||
|     # Display id is set via a trigger in the database |     # Display id is set via a trigger in the database | ||||||
|     # So we need to specifically fetch it after the record is created |     # So we need to specifically fetch it after the record is created | ||||||
|   | |||||||
| @@ -20,7 +20,8 @@ module Enterprise::Api::V1::Accounts::ConversationsController | |||||||
|     response = Captain::Copilot::ChatService.new( |     response = Captain::Copilot::ChatService.new( | ||||||
|       assistant, |       assistant, | ||||||
|       previous_messages: copilot_params[:previous_messages], |       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]) |     ).generate_response(copilot_params[:message]) | ||||||
|  |  | ||||||
|     render json: { message: response['response'] } |     render json: { message: response['response'] } | ||||||
|   | |||||||
| @@ -9,6 +9,7 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService | |||||||
|     @assistant = assistant |     @assistant = assistant | ||||||
|     @conversation_history = config[:conversation_history] |     @conversation_history = config[:conversation_history] | ||||||
|     @previous_messages = config[:previous_messages] || [] |     @previous_messages = config[:previous_messages] || [] | ||||||
|  |     @language = config[:language] || 'english' | ||||||
|     @messages = [system_message, conversation_history_context] + @previous_messages |     @messages = [system_message, conversation_history_context] + @previous_messages | ||||||
|     @response = '' |     @response = '' | ||||||
|   end |   end | ||||||
| @@ -27,7 +28,7 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService | |||||||
|   def system_message |   def system_message | ||||||
|     { |     { | ||||||
|       role: 'system', |       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 |   end | ||||||
|  |  | ||||||
|   | |||||||
| @@ -26,7 +26,9 @@ class Captain::Llm::ContactNotesService < Llm::BaseOpenAiService | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   def chat_parameters |   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, |       model: @model, | ||||||
|       response_format: { type: 'json_object' }, |       response_format: { type: 'json_object' }, | ||||||
|   | |||||||
| @@ -89,7 +89,9 @@ class Captain::Llm::ConversationFaqService < Llm::BaseOpenAiService | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   def chat_parameters |   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, |       model: @model, | ||||||
|       response_format: { type: 'json_object' }, |       response_format: { type: 'json_object' }, | ||||||
|   | |||||||
| @@ -56,7 +56,7 @@ class Captain::Llm::SystemPromptsService | |||||||
|       SYSTEM_PROMPT_MESSAGE |       SYSTEM_PROMPT_MESSAGE | ||||||
|     end |     end | ||||||
|  |  | ||||||
|     def copilot_response_generator(product_name) |     def copilot_response_generator(product_name, language) | ||||||
|       <<~SYSTEM_PROMPT_MESSAGE |       <<~SYSTEM_PROMPT_MESSAGE | ||||||
|         [Identity] |         [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. |         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] |         [Response Guidelines] | ||||||
|         - Use natural, polite, and conversational language that is clear and easy to follow. Keep sentences short and use simple words. |         - 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. |         - 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. |         - 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. |         - If the query is unclear, ask concise clarifying questions instead of making assumptions. | ||||||
|   | |||||||
| @@ -2,17 +2,71 @@ require 'rails_helper' | |||||||
|  |  | ||||||
| RSpec.describe Captain::Copilot::ChatService do | RSpec.describe Captain::Copilot::ChatService do | ||||||
|   let(:account) { create(:account, custom_attributes: { plan_name: 'startups' }) } |   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(:captain_inbox_association) { create(:captain_inbox, captain_assistant: assistant, inbox: inbox) } | ||||||
|  |  | ||||||
|   let(:mock_captain_agent) { instance_double(Captain::Agent) } |   let(:mock_captain_agent) { instance_double(Captain::Agent) } | ||||||
|   let(:mock_captain_tool) { instance_double(Captain::Tool) } |   let(:mock_captain_tool) { instance_double(Captain::Tool) } | ||||||
|   let(:mock_openai_client) { instance_double(OpenAI::Client) } |   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 |   describe '#execute' do | ||||||
|     before 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(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(mock_openai_client).to receive(:chat).and_return({ choices: [{ message: { content: '{ "result": "Hey" }' } }] }.with_indifferent_access) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -145,5 +145,25 @@ RSpec.describe Captain::Llm::ConversationFaqService do | |||||||
|         { role: 'user', content: conversation.to_llm_text } |         { role: 'user', content: conversation.to_llm_text } | ||||||
|       ) |       ) | ||||||
|     end |     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 | ||||||
| end | end | ||||||
|   | |||||||
| @@ -108,4 +108,30 @@ RSpec.describe Account do | |||||||
|       expect(ActiveRecord::Base.connection.execute(query).count).to eq(0) |       expect(ActiveRecord::Base.connection.execute(query).count).to eq(0) | ||||||
|     end |     end | ||||||
|   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 | end | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Shivam Mishra
					Shivam Mishra