UX: additional AI translation chart adjustments (#35454)

Follow-up to be8b2b3, which wasn't quite enough adjustment to get the
desired outcome. At the moment it's correctly showing how many posts
need to be translated (1203), but it's still showing the total of posts
in the default locale (190284), rather than distinguishing the total
number of posts that need translating in that language.

<img width="2222" height="482" alt="image"
src="https://github.com/user-attachments/assets/0e4e1bee-0033-4b98-8f0c-b90d95845203"
/>

This PR further adjusts the chart to show "remaining work" for each
locale, rather than showing the total number of posts. So the outcome
should be more like

<img width="2210" height="638" alt="image"
src="https://github.com/user-attachments/assets/baa099d4-20f0-40bc-8fbb-3d2d5308e97a"
/>


I've also updated the tooltip on the chart to clarify that this is the
number of posts not in the language that need to be translated into it
This commit is contained in:
Kris
2025-10-16 14:57:04 -04:00
committed by GitHub
parent 57bec16515
commit bf4d07a89c
7 changed files with 122 additions and 78 deletions
@@ -23,17 +23,10 @@ module DiscourseAi
)
end
candidates = DiscourseAi::Translation::PostCandidates
candidates.get_total_and_with_locale_count in { total:, posts_with_detected_locale: }
totals = DiscourseAi::Translation::PostCandidates.get_total_and_with_locale_count
progress = DiscourseAi::Translation::PostCandidates.get_completion_all_locales
render json:
base_result.merge(
{
translation_progress: candidates.get_completion_all_locales,
total:,
posts_with_detected_locale:,
},
)
render json: base_result.merge(totals.merge(translation_progress: progress))
end
private
@@ -86,11 +86,6 @@ export default class AiTranslations extends Component {
});
}
get showDefaultLocaleNote() {
const defaultLocale = this.siteSettings.default_locale;
return this.selectedLocales.includes(defaultLocale);
}
@action
navigateToLocalizationSettings() {
this.router.transitionTo("adminConfig.localization.settings", {
@@ -197,7 +192,7 @@ export default class AiTranslations extends Component {
}
get chartRightPadding() {
const max = Math.max(...this.data.map(({ done }) => done));
const max = Math.max(...this.data.map(({ total }) => total));
switch (true) {
case max >= 100000:
return 90;
@@ -232,21 +227,21 @@ export default class AiTranslations extends Component {
}
const colors = this.chartColors;
const defaultLocale = this.siteSettings.default_locale;
const processedData = this.data.map(({ locale, total, done }) => {
const donePercentage = (total > 0 ? (done / total) * 100 : 0).toFixed(0);
const isDefault = locale === defaultLocale;
const localeName = this.languageNameLookup.getLanguageName(locale);
const languageNameForTooltip = localeName.split(" (")[0];
return {
locale: isDefault ? `${localeName}*` : localeName,
locale: localeName,
done,
total,
donePercentage,
tooltip: [
i18n("discourse_ai.translations.progress_chart.tooltip_translated", {
done,
total,
language: languageNameForTooltip,
}),
],
};
@@ -257,7 +252,7 @@ export default class AiTranslations extends Component {
{
tooltip: processedData.map(({ tooltip }) => tooltip),
data: processedData.map(({ donePercentage }) => donePercentage),
totalItems: processedData.map(({ done }) => done),
totalItems: processedData.map(({ total }) => total),
backgroundColor: colors.progress,
barThickness: 30,
borderRadius: 4,
@@ -454,13 +449,6 @@ export default class AiTranslations extends Component {
class="ai-translations__chart"
/>
</div>
{{#if this.showDefaultLocaleNote}}
<div class="ai-translations__default-locale-note">
{{i18n
"discourse_ai.translations.progress_chart.default_locale_note"
}}
</div>
{{/if}}
</:content>
</AdminConfigAreaCard>
@@ -8,14 +8,6 @@
margin-top: 2em;
}
&__default-locale-note {
margin-top: var(--space-4);
padding: var(--space-2);
font-size: var(--font-down-1);
color: var(--primary-medium);
font-style: italic;
}
&__locale-input-row {
display: flex;
align-items: center;
@@ -342,13 +342,12 @@ en:
incomplete_language_detection_description: "%{done} of %{total} eligible posts have their language detected."
backfill_disabled: "Backfilling is disabled, only new posts will be translated."
description_tooltip:
one: 'There is %{count} post eligible for translation.'
other: 'There are %{count} posts eligible for translation.'
one: 'There is %{count} post ready to translate.'
other: 'There are %{count} posts ready to translate.'
progress_chart:
title: "Translation Progress"
data_label: "%{percentage}%"
tooltip_translated: "%{done} out of %{total} posts translated"
default_locale_note: "*This is the site's default locale. Only posts needing translation into it are shown."
tooltip_translated: "%{done} out of %{total} non-%{language} posts translated"
bar_done:
one: "%{count} post"
other: "%{count} posts"
@@ -85,14 +85,12 @@ module DiscourseAi
def self.completion_all_locales
supported = SiteSetting.content_localization_supported_locales.split("|")
default_locale = SiteSetting.default_locale
values_rows = supported.map { |loc| "('#{loc}')" }.join(", ")
sql = <<~SQL
WITH supported AS (
SELECT localestr,
split_part(localestr, '_', 1) AS base,
(localestr = '#{default_locale}') AS is_default
split_part(localestr, '_', 1) AS base
FROM (VALUES #{values_rows}) AS t(localestr)
),
eligible_posts AS (
@@ -101,12 +99,12 @@ module DiscourseAi
all_posts_count AS (
SELECT COUNT(*)::bigint AS count FROM eligible_posts
),
non_default_locale_counts AS (
non_target_locale_counts AS (
SELECT s.base,
COUNT(*)::bigint AS count
FROM eligible_posts p
CROSS JOIN supported s
WHERE s.is_default AND split_part(p.locale, '_', 1) != s.base
WHERE split_part(p.locale, '_', 1) != s.base
GROUP BY s.base
),
done_per_base AS (
@@ -114,25 +112,20 @@ module DiscourseAi
COUNT(*)::bigint AS done
FROM eligible_posts p
JOIN supported s ON TRUE
WHERE split_part(p.locale, '_', 1) = s.base
OR EXISTS (
SELECT 1
FROM post_localizations pl
WHERE pl.post_id = p.id
AND split_part(pl.locale, '_', 1) = s.base
)
WHERE split_part(p.locale, '_', 1) != s.base AND EXISTS (
SELECT 1
FROM post_localizations pl
WHERE pl.post_id = p.id
AND split_part(pl.locale, '_', 1) = s.base
)
GROUP BY s.base
)
SELECT s.localestr AS locale,
COALESCE(d.done, 0) AS done,
CASE
WHEN s.is_default THEN COALESCE(ndl.count, 0)
ELSE a.count
END AS total
COALESCE(ntl.count, 0) AS total
FROM supported s
LEFT JOIN done_per_base d ON d.base = s.base
LEFT JOIN non_default_locale_counts ndl ON ndl.base = s.base
CROSS JOIN all_posts_count a
LEFT JOIN non_target_locale_counts ntl ON ntl.base = s.base
SQL
DB.query(sql).map { |r| { locale: r.locale, done: r.done, total: r.total } }
@@ -60,7 +60,11 @@ describe DiscourseAi::Translation::PostCandidates do
end
describe ".get_completion_all_locales" do
before { SiteSetting.content_localization_supported_locales = "en_GB|pt|es" }
before do
SiteSetting.content_localization_supported_locales = "en_GB|pt|es"
SiteSetting.ai_translation_backfill_max_age_days = 30
SiteSetting.ai_translation_backfill_limit_to_public_content = false
end
it "returns empty state when no posts exist" do
Post.delete_all
@@ -78,11 +82,18 @@ describe DiscourseAi::Translation::PostCandidates do
it "returns progress grouped by base locale (of en_GB) and correct totals" do
post1 = Fabricate(:post, locale: "en_GB")
post2 = Fabricate(:post, locale: "fr")
Fabricate(:post, locale: "es")
post3 = Fabricate(:post, locale: "es")
Fabricate(:post, locale: nil) # not eligible
# add an en_GB localization to a non-en base post
Fabricate(:post_localization, post: post2, locale: "en")
PostLocalization.create!(
post: post2,
locale: "en",
raw: "Translated to English",
cooked: "<p>Translated to English</p>",
post_version: post2.version,
localizer_user_id: Discourse.system_user.id,
)
result = DiscourseAi::Translation::PostCandidates.completion_all_locales
expect(result.length).to eq(3)
@@ -91,9 +102,9 @@ describe DiscourseAi::Translation::PostCandidates do
en_entry = result.find { |r| r[:locale] == "en_GB" }
expect(en_entry).to be_present
# post1 (en_GB base=en) + post2 (localization en_GB base=en)
expect(en_entry[:done]).to eq(2)
expect(en_entry[:total]).to eq(3)
# total is non-English posts (post2 + post3)
expect(en_entry[:done]).to eq(1)
expect(en_entry[:total]).to eq(2)
pt_entry = result.find { |r| r[:locale] == "pt" }
expect(pt_entry).to be_present
@@ -101,8 +112,8 @@ describe DiscourseAi::Translation::PostCandidates do
expect(pt_entry[:total]).to eq(3)
es_entry = result.find { |r| r[:locale] == "es" }
expect(es_entry).to be_present
expect(es_entry[:done]).to eq(1)
expect(es_entry[:total]).to eq(3)
expect(es_entry[:done]).to eq(0)
expect(es_entry[:total]).to eq(2)
fr_entry = result.find { |r| r[:locale] == "fr" }
expect(fr_entry).to be_nil
end
@@ -22,10 +22,19 @@ describe DiscourseAi::Admin::AiTranslationsController do
SiteSetting.ai_translation_backfill_max_age_days = 30
SiteSetting.ai_translation_backfill_limit_to_public_content = false
Fabricate.times(14, :post, locale: "en")
Fabricate.times(1, :post, locale: "fr")
english_posts = Fabricate.times(14, :post, locale: "en")
french_post = Fabricate(:post, locale: "fr")
Fabricate.times(4, :post)
PostLocalization.create!(
post: french_post,
locale: "en",
raw: "Translated to English",
cooked: "<p>Translated to English</p>",
post_version: french_post.version,
localizer_user_id: admin.id,
)
get "/admin/plugins/discourse-ai/ai-translations.json"
expect(response.status).to eq(200)
@@ -43,17 +52,70 @@ describe DiscourseAi::Admin::AiTranslationsController do
expect(locale_data["locale"]).to eq("en")
# en is the default locale, so total should only be posts requiring translation (1 French post)
expect(locale_data["total"]).to eq(1)
expect(locale_data["done"]).to eq(14)
# done should be 1 because we translated the French post to English
expect(locale_data["done"]).to eq(1)
end
it "shows all posts for non-default locales but only posts requiring translation for the default locale" do
it "shows only posts requiring translation for all locales (consistent behavior)" do
SiteSetting.ai_translation_backfill_max_age_days = 30
SiteSetting.ai_translation_backfill_limit_to_public_content = false
SiteSetting.default_locale = "en"
Fabricate.times(100, :post, locale: "en")
Fabricate.times(10, :post, locale: "fr")
Fabricate.times(5, :post, locale: "es")
english_posts = Fabricate.times(100, :post, locale: "en")
french_posts = Fabricate.times(10, :post, locale: "fr")
spanish_posts = Fabricate.times(5, :post, locale: "es")
french_posts
.take(8)
.each do |post|
PostLocalization.create!(
post: post,
locale: "en",
raw: "Translated to English",
cooked: "<p>Translated to English</p>",
post_version: post.version,
localizer_user_id: admin.id,
)
end
spanish_posts
.take(3)
.each do |post|
PostLocalization.create!(
post: post,
locale: "en",
raw: "Translated to English",
cooked: "<p>Translated to English</p>",
post_version: post.version,
localizer_user_id: admin.id,
)
end
english_posts
.take(50)
.each do |post|
PostLocalization.create!(
post: post,
locale: "fr",
raw: "Translated to French",
cooked: "<p>Translated to French</p>",
post_version: post.version,
localizer_user_id: admin.id,
)
end
english_posts
.drop(50)
.take(30)
.each do |post|
PostLocalization.create!(
post: post,
locale: "es",
raw: "Translated to Spanish",
cooked: "<p>Translated to Spanish</p>",
post_version: post.version,
localizer_user_id: admin.id,
)
end
get "/admin/plugins/discourse-ai/ai-translations.json"
@@ -65,14 +127,20 @@ describe DiscourseAi::Admin::AiTranslationsController do
fr_data = progress.find { |p| p["locale"] == "fr" }
es_data = progress.find { |p| p["locale"] == "es" }
# 15 non-English posts (10 fr + 5 es)
expect(en_data["total"]).to eq(15)
expect(en_data["done"]).to eq(100)
# 11 translated to English (8 fr + 3 es)
expect(en_data["done"]).to eq(11)
expect(fr_data["total"]).to eq(115)
expect(fr_data["done"]).to eq(10)
# 105 non-French posts (100 en + 5 es)
expect(fr_data["total"]).to eq(105)
# 50 translated to French
expect(fr_data["done"]).to eq(50)
expect(es_data["total"]).to eq(115)
expect(es_data["done"]).to eq(5)
# 110 non-Spanish posts (100 en + 10 fr)
expect(es_data["total"]).to eq(110)
# 30 translated to Spanish
expect(es_data["done"]).to eq(30)
end
it "returns empty when no locales are supported" do