From 751c28d94d33c78b499cd6b0602b544dd0328839 Mon Sep 17 00:00:00 2001 From: Pranav Date: Fri, 24 Apr 2026 08:51:26 -0700 Subject: [PATCH] feat(ee): Add article translation via LLM in help center (#14136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the ability to translate help center articles to other languages using Captain's LLM infrastructure. Translated articles are created as drafts linked to the source article. Fixes https://linear.app/chatwoot/issue/CW-6901/translate-article-to-another-language **How to test** 1. Navigate to Help Center → Articles for a portal with multiple locales 2. Click the three-dot menu on any article → "Translate" 3. Select a target language and category → click Translate 4. Switch to the target locale — the translated article appears as a draft 5. Try translating the same article again — a warning shows the existing translation with a link to open it in a new tab 6. Click "Overwrite and translate" to replace the existing translation https://github.com/user-attachments/assets/1d2e991b-f0ac-403a-bcc1-2181b5731ea4 --- .../articles/bulk_actions_controller.rb | 19 ++ .../dashboard/api/helpCenter/articles.js | 7 + .../HelpCenter/ArticleCard/ArticleCard.vue | 22 +- .../Pages/ArticlePage/ArticleList.vue | 6 + .../Pages/ArticlePage/ArticlesPage.vue | 17 +- .../Pages/ArticlePage/BulkTranslateDialog.vue | 249 ++++++++++++++++++ .../dashboard/helper/portalHelper.js | 13 +- .../dashboard/i18n/locale/en/helpCenter.json | 18 ++ .../modules/helpCenterArticles/actions.js | 14 + config/locales/en.yml | 5 + config/routes.rb | 5 + .../articles/bulk_actions_controller.rb | 68 +++++ .../jobs/captain/articles/translate_job.rb | 59 +++++ .../llm/article_translation_service.rb | 62 +++++ .../articles/bulk_actions_controller_spec.rb | 179 +++++++++++++ .../captain/articles/translate_job_spec.rb | 134 ++++++++++ .../llm/article_translation_service_spec.rb | 67 +++++ 17 files changed, 939 insertions(+), 5 deletions(-) create mode 100644 app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb create mode 100644 app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/BulkTranslateDialog.vue create mode 100644 enterprise/app/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller.rb create mode 100644 enterprise/app/jobs/captain/articles/translate_job.rb create mode 100644 enterprise/app/services/captain/llm/article_translation_service.rb create mode 100644 spec/enterprise/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller_spec.rb create mode 100644 spec/enterprise/jobs/captain/articles/translate_job_spec.rb create mode 100644 spec/enterprise/services/captain/llm/article_translation_service_spec.rb diff --git a/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb b/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb new file mode 100644 index 000000000..584e3dbf2 --- /dev/null +++ b/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb @@ -0,0 +1,19 @@ +class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController + before_action :portal + before_action :check_authorization + + def translate + head :not_implemented + end + + private + + def portal + @portal ||= Current.account.portals.find_by!(slug: params[:portal_id]) + end + + def check_authorization + authorize(Article, :create?) + end +end +Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController') diff --git a/app/javascript/dashboard/api/helpCenter/articles.js b/app/javascript/dashboard/api/helpCenter/articles.js index 727340ed5..781570d0b 100644 --- a/app/javascript/dashboard/api/helpCenter/articles.js +++ b/app/javascript/dashboard/api/helpCenter/articles.js @@ -72,6 +72,13 @@ class ArticlesAPI extends PortalsAPI { category_slug: categorySlug, }); } + + bulkTranslate({ portalSlug, articleIds, locale, categoryId, force = false }) { + return axios.post( + `${this.url}/${portalSlug}/articles/bulk_actions/translate`, + { ids: articleIds, locale, category_id: categoryId, force } + ); + } } export default new ArticlesAPI(); diff --git a/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue b/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue index ce9e9db36..25eda255a 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue @@ -9,6 +9,9 @@ import { ARTICLE_STATUSES, } from 'dashboard/helper/portalHelper'; +import { useMapGetter } from 'dashboard/composables/store.js'; +import { useConfig } from 'dashboard/composables/useConfig'; +import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import Icon from 'dashboard/components-next/icon/Icon.vue'; import CardLayout from 'dashboard/components-next/CardLayout.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; @@ -52,6 +55,21 @@ const { t } = useI18n(); const [showActionsDropdown, toggleDropdown] = useToggle(); +const currentAccountId = useMapGetter('getCurrentAccountId'); +const isFeatureEnabledonAccount = useMapGetter( + 'accounts/isFeatureEnabledonAccount' +); +const { isEnterprise } = useConfig(); + +const isTranslationAvailable = computed( + () => + isEnterprise && + isFeatureEnabledonAccount.value( + currentAccountId.value, + FEATURE_FLAGS.CAPTAIN_TASKS + ) +); + const articleMenuItems = computed(() => { const commonItems = Object.entries(ARTICLE_MENU_ITEMS).reduce( (acc, [key, item]) => { @@ -64,7 +82,9 @@ const articleMenuItems = computed(() => { const statusItems = ( ARTICLE_MENU_OPTIONS[props.status] || ARTICLE_MENU_OPTIONS[ARTICLE_STATUSES.PUBLISHED] - ).map(key => commonItems[key]); + ) + .filter(key => key !== 'translate' || isTranslationAvailable.value) + .map(key => commonItems[key]); return [...statusItems, commonItems.delete]; }); diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue index cc7c97000..c46a965bc 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue @@ -22,6 +22,8 @@ const props = defineProps({ }, }); +const emit = defineEmits(['translateArticle']); + const { ARTICLE_STATUS_TYPES } = wootConstants; const router = useRouter(); @@ -152,6 +154,10 @@ const handleArticleAction = async (action, { status, id }) => { }; const updateArticle = ({ action, value, id }) => { + if (action === 'translate') { + emit('translateArticle', id); + return; + } const status = action !== 'delete' ? getArticleStatus(value) : null; handleArticleAction(action, { status, id }); }; diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue index e31d40d8a..f1e177505 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue @@ -1,5 +1,5 @@ + diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/BulkTranslateDialog.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/BulkTranslateDialog.vue new file mode 100644 index 000000000..c2551830d --- /dev/null +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/BulkTranslateDialog.vue @@ -0,0 +1,249 @@ + + + diff --git a/app/javascript/dashboard/helper/portalHelper.js b/app/javascript/dashboard/helper/portalHelper.js index 37d09337f..33a2a822a 100644 --- a/app/javascript/dashboard/helper/portalHelper.js +++ b/app/javascript/dashboard/helper/portalHelper.js @@ -91,6 +91,13 @@ export const ARTICLE_MENU_ITEMS = { action: 'archive', icon: 'i-lucide-archive-restore', }, + translate: { + label: + 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.TRANSLATE', + value: 'translate', + action: 'translate', + icon: 'i-lucide-languages', + }, delete: { label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.DELETE', value: 'delete', @@ -100,9 +107,9 @@ export const ARTICLE_MENU_ITEMS = { }; export const ARTICLE_MENU_OPTIONS = { - [ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft'], - [ARTICLE_STATUSES.DRAFT]: ['publish', 'archive'], - [ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive'], + [ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft', 'translate'], + [ARTICLE_STATUSES.DRAFT]: ['publish', 'archive', 'translate'], + [ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive', 'translate'], }; export const ARTICLE_TABS = { diff --git a/app/javascript/dashboard/i18n/locale/en/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json index ffeca222a..bb9bf2e99 100644 --- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json +++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json @@ -525,6 +525,7 @@ "PUBLISH": "Publish", "DRAFT": "Draft", "ARCHIVE": "Archive", + "TRANSLATE": "Translate", "DELETE": "Delete" }, "STATUS": { @@ -579,6 +580,23 @@ "TITLE": "There are no articles in this category", "SUBTITLE": "Articles in this category will appear here" } + }, + "BULK_TRANSLATE": { + "TITLE": "Translate article | Translate {count} articles", + "DESCRIPTION": "Translate the selected article to another language. | Translate the selected articles to another language.", + "LOCALE_LABEL": "Target language", + "LOCALE_PLACEHOLDER": "Select a language", + "CATEGORY_LABEL": "Target category", + "CATEGORY_PLACEHOLDER": "Select a category", + "OPTIONAL": "(optional)", + "CONFIRM": "Translate", + "CONFIRM_OVERWRITE": "Overwrite and translate", + "DUPLICATE_WARNING": "A translation already exists for this article in the selected language. | Translations already exist for {count} articles in the selected language.", + "DUPLICATE_CONFIRM_HINT": "Click translate again to overwrite the existing translation.", + "API": { + "SUCCESS_MESSAGE": "Translation in progress. The article will appear as a draft once ready.", + "ERROR_MESSAGE": "Failed to start translation. Please try again." + } } }, "CATEGORY_PAGE": { diff --git a/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js b/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js index 13a2b4899..229a42a5d 100644 --- a/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js +++ b/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js @@ -166,4 +166,18 @@ export const actions = { throw error; } }, + + bulkTranslate: async ( + _, + { portalSlug, articleIds, locale, categoryId, force = false } + ) => { + const { data } = await articlesAPI.bulkTranslate({ + portalSlug, + articleIds, + locale, + categoryId, + force, + }); + return data; + }, }; diff --git a/config/locales/en.yml b/config/locales/en.yml index 1841db332..36f115bab 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -488,6 +488,11 @@ en: agent_capacity_policy: inbox_already_assigned: 'Inbox has already been assigned to this policy' portals: + articles: + captain_not_available: 'Translation requires Captain to be enabled for this account' + locale_not_available: 'Locale not available in this portal' + category_not_found: 'Category not found in this portal' + no_articles_found: 'No articles found to process' send_instructions: email_required: 'Email is required' invalid_email_format: 'Invalid email format' diff --git a/config/routes.rb b/config/routes.rb index 2461539ad..c6111d317 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -358,6 +358,11 @@ Rails.application.routes.draw do resources :categories do post :reorder, on: :collection end + namespace :articles do + resource :bulk_actions, only: [] do + post :translate + end + end resources :articles do post :reorder, on: :collection end diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller.rb new file mode 100644 index 000000000..87ad54d24 --- /dev/null +++ b/enterprise/app/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller.rb @@ -0,0 +1,68 @@ +module Enterprise::Api::V1::Accounts::Articles::BulkActionsController + def translate + return unless validate_translate_params? + + duplicates = find_existing_translations + if duplicates.any? && !ActiveModel::Type::Boolean.new.cast(permitted_params[:force]) + return render json: { + duplicate_articles: duplicates.map { |a| { id: a.id, title: a.title } } + }, status: :conflict + end + + @articles.find_each do |article| + Captain::Articles::TranslateJob.perform_later( + Current.account, article.id, @locale, @category&.id, Current.user + ) + end + + head :ok + end + + private + + def permitted_params + params.permit(:locale, :category_id, :force, ids: []) + end + + def validate_translate_params? + @locale = permitted_params[:locale] + @category = @portal.categories.find_by(id: permitted_params[:category_id], locale: @locale) + @articles = @portal.articles.where(id: permitted_params[:ids]) + + captain_available? && valid_locale? && valid_category? && valid_articles? + end + + def find_existing_translations + root_ids = @articles.map { |a| Article.find_root_article_id(a) } + @portal.articles.where(associated_article_id: root_ids, locale: @locale) + end + + def captain_available? + return true if Current.account.feature_enabled?('captain_tasks') + + render_could_not_create_error(I18n.t('portals.articles.captain_not_available')) + false + end + + def valid_locale? + return true if @portal.config['allowed_locales']&.include?(@locale) + + render_could_not_create_error(I18n.t('portals.articles.locale_not_available')) + false + end + + def valid_category? + return true if permitted_params[:category_id].blank? + return true if @category.present? + + render_could_not_create_error(I18n.t('portals.articles.category_not_found')) + false + end + + def valid_articles? + return true if @articles.any? + + render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) + false + end +end diff --git a/enterprise/app/jobs/captain/articles/translate_job.rb b/enterprise/app/jobs/captain/articles/translate_job.rb new file mode 100644 index 000000000..c3524cbff --- /dev/null +++ b/enterprise/app/jobs/captain/articles/translate_job.rb @@ -0,0 +1,59 @@ +class Captain::Articles::TranslateJob < ApplicationJob + queue_as :low + + def perform(account, article_id, target_locale, target_category_id, user) + @account = account + @source_article = account.articles.find(article_id) + + target_language = language_name_for(target_locale) + + translated_title = translate(@source_article.title, target_language: target_language, type: :title) + translated_content = if @source_article.content.present? + translate(@source_article.content, target_language: target_language, type: :content) + else + @source_article.content + end + + existing = find_existing_translation(target_locale) + + if existing + existing.update!(title: translated_title, content: translated_content, description: @source_article.description) + else + create_translated_article(translated_title, translated_content, target_locale, target_category_id, user) + end + end + + private + + def translate(text, target_language:, type:) + response = Captain::Llm::ArticleTranslationService.new( + account: @account, text: text, target_language: target_language, type: type + ).perform + raise "Translation failed: #{response[:error]}" if response[:error] + + response[:message] + end + + def find_existing_translation(target_locale) + root_id = Article.find_root_article_id(@source_article) + @source_article.portal.articles.find_by(associated_article_id: root_id, locale: target_locale) + end + + def create_translated_article(translated_title, translated_content, target_locale, target_category_id, user) + @source_article.portal.articles.create!( + title: translated_title, + content: translated_content, + description: @source_article.description, + category_id: target_category_id, + locale: target_locale, + author_id: user.id, + status: :draft, + associated_article_id: Article.find_root_article_id(@source_article) + ) + end + + def language_name_for(locale_code) + language_map = YAML.load_file(Rails.root.join('config/languages/language_map.yml')) + language_map[locale_code] || locale_code + end +end diff --git a/enterprise/app/services/captain/llm/article_translation_service.rb b/enterprise/app/services/captain/llm/article_translation_service.rb new file mode 100644 index 000000000..5db26088e --- /dev/null +++ b/enterprise/app/services/captain/llm/article_translation_service.rb @@ -0,0 +1,62 @@ +class Captain::Llm::ArticleTranslationService < Captain::BaseTaskService + TYPES = %i[title content].freeze + + pattr_initialize [:account!, :text!, :target_language!, :type!] + + def perform + raise ArgumentError, "Invalid type: #{type}" unless TYPES.include?(type) + + response = make_api_call(model: translation_model, messages: messages) + return response if response[:error] + + response.merge(message: response[:message].strip) + end + + private + + def messages + [ + { role: 'system', content: system_prompt }, + { role: 'user', content: text } + ] + end + + def system_prompt + type == :title ? title_system_prompt : content_system_prompt + end + + def event_name + 'article_translation' + end + + def llm_credential + @llm_credential ||= system_llm_credential + end + + def translation_model + @translation_model ||= InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || GPT_MODEL + end + + def title_system_prompt + <<~SYSTEM_PROMPT_MESSAGE + You are a professional translator. + Translate the following text to #{target_language}. + Return only the translated text, no explanations or extra formatting. + SYSTEM_PROMPT_MESSAGE + end + + def content_system_prompt + <<~SYSTEM_PROMPT_MESSAGE + You are a professional translator. Translate the following content to #{target_language}. + The content is markdown that may contain embedded HTML blocks. + Rules: + - Translate ONLY the visible text content (headings, paragraphs, list items, table cells, etc.). + - Preserve ALL markdown formatting exactly: headings (#), bold (**), italic (*), links, lists, code blocks, blockquotes, tables, horizontal rules. + - Preserve ALL HTML tags, attributes, and structure exactly as they are. + - Do NOT translate or modify: URLs, image src/alt attributes, link href values, class names, IDs, data attributes, code blocks, or any HTML attribute values. + - Keep all image tags (both markdown ![](url) and HTML ), iframes, and embedded media completely unchanged. + - Preserve all line breaks, blank lines, and whitespace patterns. + - Return ONLY the translated content, no wrapping or explanations. + SYSTEM_PROMPT_MESSAGE + end +end diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller_spec.rb new file mode 100644 index 000000000..31f61d977 --- /dev/null +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller_spec.rb @@ -0,0 +1,179 @@ +require 'rails_helper' + +RSpec.describe 'Article Bulk Actions API', type: :request do + include ActiveJob::TestHelper + + let(:account) { create(:account) } + let(:admin) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + let!(:portal) { create(:portal, name: 'test_portal', account: account, config: { allowed_locales: %w[en es fr] }) } + let!(:category_en) { create(:category, portal: portal, account: account, locale: 'en', slug: 'getting-started') } + let!(:category_es) { create(:category, portal: portal, account: account, locale: 'es', slug: 'primeros-pasos') } + let!(:article_one) { create(:article, category: category_en, portal: portal, account: account, author_id: admin.id) } + let!(:article_two) { create(:article, category: category_en, portal: portal, account: account, author_id: admin.id) } + + let(:translate_url) { "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/bulk_actions/translate" } + + describe 'POST articles/bulk_actions/translate' do + context 'when unauthenticated' do + it 'returns unauthorized' do + post translate_url, params: { ids: [article_one.id], locale: 'es', category_id: category_es.id }, as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when authenticated as agent' do + it 'returns unauthorized' do + post translate_url, + headers: agent.create_new_auth_token, + params: { ids: [article_one.id], locale: 'es', category_id: category_es.id }, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when captain is not enabled' do + it 'returns unprocessable entity' do + post translate_url, + headers: admin.create_new_auth_token, + params: { ids: [article_one.id], locale: 'es', category_id: category_es.id }, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'when authenticated as admin' do + before do + account.enable_features!('captain_tasks') + end + + it 'enqueues translation jobs for each article' do + expect do + post translate_url, + headers: admin.create_new_auth_token, + params: { ids: [article_one.id, article_two.id], locale: 'es', category_id: category_es.id }, + as: :json + end.to have_enqueued_job(Captain::Articles::TranslateJob).exactly(2).times + + expect(response).to have_http_status(:ok) + end + + it 'enqueues job with correct arguments' do + expect do + post translate_url, + headers: admin.create_new_auth_token, + params: { ids: [article_one.id], locale: 'es', category_id: category_es.id }, + as: :json + end.to have_enqueued_job(Captain::Articles::TranslateJob).with( + account, article_one.id, 'es', category_es.id, admin + ) + + expect(response).to have_http_status(:ok) + end + + it 'returns unprocessable entity for invalid locale' do + post translate_url, + headers: admin.create_new_auth_token, + params: { ids: [article_one.id], locale: 'zh', category_id: category_es.id }, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns unprocessable entity for invalid category' do + post translate_url, + headers: admin.create_new_auth_token, + params: { ids: [article_one.id], locale: 'es', category_id: 0 }, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns unprocessable entity when category locale does not match requested locale' do + post translate_url, + headers: admin.create_new_auth_token, + params: { ids: [article_one.id], locale: 'es', category_id: category_en.id }, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'enqueues job with nil category when category_id is omitted' do + expect do + post translate_url, + headers: admin.create_new_auth_token, + params: { ids: [article_one.id], locale: 'es' }, + as: :json + end.to have_enqueued_job(Captain::Articles::TranslateJob).with( + account, article_one.id, 'es', nil, admin + ) + + expect(response).to have_http_status(:ok) + end + + it 'enqueues job with nil category when category_id is blank' do + expect do + post translate_url, + headers: admin.create_new_auth_token, + params: { ids: [article_one.id], locale: 'es', category_id: '' }, + as: :json + end.to have_enqueued_job(Captain::Articles::TranslateJob).with( + account, article_one.id, 'es', nil, admin + ) + + expect(response).to have_http_status(:ok) + end + + it 'returns unprocessable entity when no articles found' do + post translate_url, + headers: admin.create_new_auth_token, + params: { ids: [0], locale: 'es', category_id: category_es.id }, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + + context 'when translations already exist' do + let!(:existing_translation) do + create(:article, portal: portal, category: category_es, account: account, author_id: admin.id, + locale: 'es', associated_article_id: article_one.id) + end + + it 'returns conflict with duplicate articles' do + post translate_url, + headers: admin.create_new_auth_token, + params: { ids: [article_one.id], locale: 'es', category_id: category_es.id }, + as: :json + + expect(response).to have_http_status(:conflict) + body = response.parsed_body + expect(body['duplicate_articles'].length).to eq(1) + expect(body['duplicate_articles'].first['id']).to eq(existing_translation.id) + end + + it 'does not enqueue jobs when duplicates found without force' do + expect do + post translate_url, + headers: admin.create_new_auth_token, + params: { ids: [article_one.id], locale: 'es', category_id: category_es.id }, + as: :json + end.not_to have_enqueued_job(Captain::Articles::TranslateJob) + end + + it 'enqueues jobs when force is true' do + expect do + post translate_url, + headers: admin.create_new_auth_token, + params: { ids: [article_one.id], locale: 'es', category_id: category_es.id, force: true }, + as: :json + end.to have_enqueued_job(Captain::Articles::TranslateJob).exactly(1).times + + expect(response).to have_http_status(:ok) + end + end + end + end +end diff --git a/spec/enterprise/jobs/captain/articles/translate_job_spec.rb b/spec/enterprise/jobs/captain/articles/translate_job_spec.rb new file mode 100644 index 000000000..119c93b1c --- /dev/null +++ b/spec/enterprise/jobs/captain/articles/translate_job_spec.rb @@ -0,0 +1,134 @@ +require 'rails_helper' + +RSpec.describe Captain::Articles::TranslateJob, type: :job do + let(:account) { create(:account) } + let(:user) { create(:user, account: account, role: :administrator) } + let!(:portal) { create(:portal, account: account, config: { allowed_locales: %w[en es] }) } + let!(:category_en) { create(:category, portal: portal, account: account, locale: 'en', slug: 'getting-started') } + let!(:category_es) { create(:category, portal: portal, account: account, locale: 'es', slug: 'primeros-pasos') } + let!(:article) do + create(:article, portal: portal, category: category_en, account: account, author: user, + title: 'Getting Started', content: '# Welcome\nThis is a guide.') + end + + let(:title_service) { instance_double(Captain::Llm::ArticleTranslationService) } + let(:content_service) { instance_double(Captain::Llm::ArticleTranslationService) } + + before do + allow(Captain::Llm::ArticleTranslationService).to receive(:new).with(hash_including(type: :title)).and_return(title_service) + allow(Captain::Llm::ArticleTranslationService).to receive(:new).with(hash_including(type: :content)).and_return(content_service) + allow(title_service).to receive(:perform).and_return(message: 'Primeros pasos') + allow(content_service).to receive(:perform).and_return(message: '# Bienvenido\nEsta es una guía.') + end + + it 'queues on the low queue' do + expect { described_class.perform_later(account, article.id, 'es', category_es.id, user) } + .to have_enqueued_job.on_queue('low') + end + + it 'creates a translated article as draft' do + expect do + described_class.perform_now(account, article.id, 'es', category_es.id, user) + end.to change(Article, :count).by(1) + + translated = Article.last + expect(translated).to have_attributes( + title: 'Primeros pasos', + content: '# Bienvenido\nEsta es una guía.', + locale: 'es', + category_id: category_es.id, + author_id: user.id, + status: 'draft', + associated_article_id: article.id + ) + end + + it 'creates a translated article without a category when target_category_id is nil' do + expect do + described_class.perform_now(account, article.id, 'es', nil, user) + end.to change(Article, :count).by(1) + + translated = Article.last + expect(translated).to have_attributes( + title: 'Primeros pasos', + locale: 'es', + category_id: nil, + status: 'draft', + associated_article_id: article.id + ) + end + + it 'calls the translation service with the correct language' do + described_class.perform_now(account, article.id, 'es', category_es.id, user) + + expect(Captain::Llm::ArticleTranslationService).to have_received(:new).with( + account: account, text: 'Getting Started', target_language: 'Spanish', type: :title + ) + expect(Captain::Llm::ArticleTranslationService).to have_received(:new).with( + account: account, text: '# Welcome\nThis is a guide.', target_language: 'Spanish', type: :content + ) + end + + it 'uses language_map for locale name resolution' do + described_class.perform_now(account, article.id, 'pt_BR', category_es.id, user) + + expect(Captain::Llm::ArticleTranslationService).to have_received(:new).with( + hash_including(target_language: 'Portuguese (Brazil)', type: :title) + ) + end + + context 'when a translation already exists' do + let!(:existing_translation) do + create(:article, portal: portal, category: category_es, account: account, author: user, + title: 'Old title', content: 'Old content', locale: 'es', + associated_article_id: article.id) + end + + it 'updates the existing translation instead of creating a new one' do + expect do + described_class.perform_now(account, article.id, 'es', category_es.id, user) + end.not_to change(Article, :count) + + existing_translation.reload + expect(existing_translation).to have_attributes( + title: 'Primeros pasos', + content: '# Bienvenido\nEsta es una guía.', + description: article.description + ) + end + end + + context 'when the source article has blank content' do + let!(:draft_article) do + create(:article, portal: portal, category: category_en, account: account, author: user, + title: 'Empty draft', content: nil, status: :draft) + end + + it 'creates the translated article with the original blank content and skips the content LLM call' do + expect do + described_class.perform_now(account, draft_article.id, 'es', category_es.id, user) + end.to change(Article, :count).by(1) + + expect(content_service).not_to have_received(:perform) + translated = Article.last + expect(translated).to have_attributes( + title: 'Primeros pasos', + content: nil, + locale: 'es', + associated_article_id: draft_article.id + ) + end + end + + context 'when translation service fails' do + before do + allow(title_service).to receive(:perform).and_return(error: 'LLM timeout') + end + + it 'raises the error and does not create an article' do + expect do + described_class.perform_now(account, article.id, 'es', category_es.id, user) + end.to raise_error(RuntimeError, /LLM timeout/).and not_change(Article, :count) + end + end +end diff --git a/spec/enterprise/services/captain/llm/article_translation_service_spec.rb b/spec/enterprise/services/captain/llm/article_translation_service_spec.rb new file mode 100644 index 000000000..1c0d83b65 --- /dev/null +++ b/spec/enterprise/services/captain/llm/article_translation_service_spec.rb @@ -0,0 +1,67 @@ +require 'rails_helper' + +RSpec.describe Captain::Llm::ArticleTranslationService do + let(:account) { create(:account) } + let(:target_language) { 'Spanish' } + + before do + create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') + allow(account).to receive(:feature_enabled?).and_call_original + allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true) + end + + describe '#perform with type: :title' do + let(:service) do + described_class.new(account: account, text: 'Getting Started', target_language: target_language, type: :title) + end + + it 'returns the stripped translated title' do + expect(service).to receive(:make_api_call) do |args| + expect(args[:messages][0][:content]).to include('professional translator') + expect(args[:messages][0][:content]).to include(target_language) + expect(args[:messages][1][:content]).to eq('Getting Started') + { message: " Primeros pasos \n" } + end + + expect(service.perform).to include(message: 'Primeros pasos') + end + end + + describe '#perform with type: :content' do + let(:content) { "# Welcome\nSome markdown." } + let(:service) do + described_class.new(account: account, text: content, target_language: target_language, type: :content) + end + + it 'returns the stripped translated content using the markdown system prompt' do + expect(service).to receive(:make_api_call) do |args| + expect(args[:messages][0][:content]).to include('markdown') + expect(args[:messages][0][:content]).to include('Preserve ALL HTML tags') + expect(args[:messages][1][:content]).to eq(content) + { message: "# Bienvenido\nAlgo de markdown.\n" } + end + + expect(service.perform).to include(message: "# Bienvenido\nAlgo de markdown.") + end + end + + describe '#perform with an invalid type' do + it 'raises ArgumentError' do + service = described_class.new(account: account, text: 'hi', target_language: target_language, type: :invalid) + + expect { service.perform }.to raise_error(ArgumentError, /Invalid type/) + end + end + + describe '#perform when the API call fails' do + let(:service) do + described_class.new(account: account, text: 'Getting Started', target_language: target_language, type: :title) + end + + it 'returns the error hash unchanged' do + allow(service).to receive(:make_api_call).and_return(error: 'LLM timeout', error_code: 500) + + expect(service.perform).to eq(error: 'LLM timeout', error_code: 500) + end + end +end