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

![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)


</details>

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
Shivam Mishra
2025-03-20 06:55:33 +05:30
committed by GitHub
parent 51ad80a61e
commit 3a4249da11
13 changed files with 136 additions and 13 deletions

View File

@@ -173,6 +173,7 @@ gem 'pgvector'
# Convert Website HTML to Markdown
gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'shopify_api'

View File

@@ -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

View File

@@ -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 `<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
def notify_creation

View File

@@ -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

View File

@@ -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

View File

@@ -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'] }

View File

@@ -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

View File

@@ -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' },

View File

@@ -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' },

View File

@@ -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 responsestypically 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.

View File

@@ -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)

View File

@@ -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

View File

@@ -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