feat(ee): Add article translation via LLM in help center (#14136)

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
This commit is contained in:
Pranav
2026-04-24 08:51:26 -07:00
committed by GitHub
parent 4959a1ff1e
commit 751c28d94d
17 changed files with 939 additions and 5 deletions
@@ -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')
@@ -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();
@@ -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];
});
@@ -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 });
};
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { ref, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
@@ -11,6 +11,7 @@ import ArticleHeaderControls from 'dashboard/components-next/HelpCenter/Pages/Ar
import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue';
import BulkTranslateDialog from './BulkTranslateDialog.vue';
const props = defineProps({
articles: {
@@ -48,6 +49,9 @@ const { t } = useI18n();
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
const isFetching = useMapGetter('articles/isFetching');
const bulkTranslateDialogRef = ref(null);
const translateArticleIds = ref([]);
const hasNoArticles = computed(
() => !isFetching.value && !props.articles.length
);
@@ -128,6 +132,11 @@ const navigateToNewArticlePage = () => {
params: { categorySlug, locale },
});
};
const handleTranslateArticle = articleId => {
translateArticleIds.value = [articleId];
bulkTranslateDialogRef.value?.dialogRef?.open();
};
</script>
<template>
@@ -170,6 +179,7 @@ const navigateToNewArticlePage = () => {
v-else-if="!hasNoArticles"
:articles="articles"
:is-category-articles="isCategoryArticles"
@translate-article="handleTranslateArticle"
/>
<ArticleEmptyState
v-else
@@ -183,5 +193,10 @@ const navigateToNewArticlePage = () => {
@click="navigateToNewArticlePage"
/>
</template>
<BulkTranslateDialog
ref="bulkTranslateDialogRef"
:selected-article-ids="translateArticleIds"
:allowed-locales="allowedLocales"
/>
</HelpCenterLayout>
</template>
@@ -0,0 +1,249 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import categoriesAPI from 'dashboard/api/helpCenter/categories.js';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
selectedArticleIds: {
type: Array,
default: () => [],
},
allowedLocales: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['translateStarted']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const router = useRouter();
const dialogRef = ref(null);
const isSubmitting = ref(false);
const selectedLocale = ref('');
const selectedCategoryId = ref('');
const targetCategories = ref([]);
const isFetchingCategories = ref(false);
const duplicateArticles = ref([]);
const currentLocale = computed(() => route.params.locale);
const localeOptions = computed(() => {
return props.allowedLocales
.filter(locale => locale.code !== currentLocale.value)
.map(locale => ({
value: locale.code,
label: `${locale.name} (${locale.code})`,
}));
});
const categoryOptions = computed(() => {
return targetCategories.value.map(category => ({
value: category.id,
label: category.name,
}));
});
const articleCount = computed(() => props.selectedArticleIds.length);
const dialogTitle = computed(() =>
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.TITLE', articleCount.value)
);
const description = computed(() =>
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DESCRIPTION', articleCount.value)
);
const hasDuplicates = computed(() => duplicateArticles.value.length > 0);
const confirmLabel = computed(() => {
if (hasDuplicates.value) {
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CONFIRM_OVERWRITE');
}
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CONFIRM');
});
const isConfirmDisabled = computed(() => {
return !selectedLocale.value || isSubmitting.value;
});
const articleEditUrl = articleId => {
const { portalSlug, categorySlug, tab } = route.params;
const resolved = router.resolve({
name: 'portals_articles_edit',
params: {
portalSlug,
locale: selectedLocale.value,
categorySlug,
tab,
articleSlug: articleId,
},
});
return resolved.href;
};
const fetchCategoriesForLocale = async locale => {
if (!locale) {
targetCategories.value = [];
return;
}
isFetchingCategories.value = true;
try {
const { data } = await categoriesAPI.get({
portalSlug: route.params.portalSlug,
locale,
});
targetCategories.value = data.payload;
} catch {
targetCategories.value = [];
} finally {
isFetchingCategories.value = false;
}
};
watch(selectedLocale, newLocale => {
selectedCategoryId.value = '';
duplicateArticles.value = [];
fetchCategoriesForLocale(newLocale);
});
const resetForm = () => {
selectedLocale.value = '';
selectedCategoryId.value = '';
targetCategories.value = [];
duplicateArticles.value = [];
};
const submitTranslation = async (force = false) => {
isSubmitting.value = true;
try {
await store.dispatch('articles/bulkTranslate', {
portalSlug: route.params.portalSlug,
articleIds: props.selectedArticleIds,
locale: selectedLocale.value,
categoryId: selectedCategoryId.value,
force,
});
useAlert(t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.API.SUCCESS_MESSAGE'));
resetForm();
dialogRef.value?.close();
emit('translateStarted');
} catch (error) {
if (error.response?.status === 409) {
duplicateArticles.value = error.response.data.duplicate_articles;
return;
}
useAlert(
error?.message ||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.API.ERROR_MESSAGE')
);
} finally {
isSubmitting.value = false;
}
};
const onConfirm = () => {
if (isConfirmDisabled.value) return;
submitTranslation(hasDuplicates.value);
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="dialogTitle"
:description="description"
:confirm-button-label="confirmLabel"
:disable-confirm-button="isConfirmDisabled"
:is-loading="isSubmitting"
@close="resetForm"
@confirm="onConfirm"
>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.LOCALE_LABEL') }}
</span>
<ComboBox
v-model="selectedLocale"
:options="localeOptions"
:placeholder="
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.LOCALE_PLACEHOLDER')
"
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
/>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CATEGORY_LABEL') }}
<span class="text-n-slate-10 font-normal">
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.OPTIONAL') }}
</span>
</span>
<ComboBox
v-model="selectedCategoryId"
:options="categoryOptions"
:disabled="!selectedLocale || isFetchingCategories"
:placeholder="
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CATEGORY_PLACEHOLDER')
"
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
/>
</div>
<div
v-if="hasDuplicates"
class="flex gap-3 p-3 rounded-xl bg-n-amber-2 border border-n-amber-5"
>
<Icon
icon="i-lucide-triangle-alert"
class="size-4 mt-0.5 text-n-amber-11 shrink-0"
/>
<div class="flex flex-col gap-2 min-w-0">
<p class="text-sm text-n-amber-12 m-0">
{{
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_WARNING',
duplicateArticles.length
)
}}
</p>
<div class="flex flex-col gap-1">
<a
v-for="article in duplicateArticles"
:key="article.id"
:href="articleEditUrl(article.id)"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-sm text-n-amber-12 underline underline-offset-2 hover:text-n-amber-11 truncate"
>
{{ article.title }}
<Icon icon="i-lucide-external-link" class="size-3 shrink-0" />
</a>
</div>
<p class="text-xs text-n-amber-11 m-0">
{{
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_CONFIRM_HINT'
)
}}
</p>
</div>
</div>
</div>
</Dialog>
</template>
@@ -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 = {
@@ -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": {
@@ -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;
},
};
+5
View File
@@ -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'
+5
View File
@@ -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
@@ -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
@@ -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
@@ -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 <img>), 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
@@ -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
@@ -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
@@ -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