mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-30 18:47:51 +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