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:
@@ -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 });
|
||||
};
|
||||
|
||||
+16
-1
@@ -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>
|
||||
|
||||
+249
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
+68
@@ -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  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
|
||||
+179
@@ -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
|
||||
Reference in New Issue
Block a user