mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-29 18:22:53 +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
|
||||
gem 'reverse_markdown'
|
||||
|
||||
gem 'iso-639'
|
||||
gem 'ruby-openai'
|
||||
|
||||
gem 'shopify_api'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'] }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user