0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-05 10:42:34 +08:00
discourse/plugins/discourse-ai/lib/translation/topic_candidates.rb
Natalie Tay 7c89948f1a
UX: Convert AI translation's max days ago setting to a specific date setting instead (#42082)
Related:
https://meta.discourse.org/t/request-for-a-date-picker-in-ai-translate-settings/405828

The backfill translation setting was "max age days". This resulted in a
running date, which meant the older topics couldn't get updated if the
date keeps running from today.
2026-07-28 18:27:34 +08:00

282 lines
9.7 KiB
Ruby
Vendored

# frozen_string_literal: true
module DiscourseAi
module Translation
class TopicCandidates < BaseCandidates
def self.progress_summary
supported_locales = SiteSetting.content_localization_supported_locales.split("|")
eligible_topics_sql = get.select(:id, :locale).to_sql
sql = <<~SQL
WITH #{DiscourseAi::Translation.supported_locale_bases_cte},
eligible_topics AS (
SELECT id,
locale,
split_part(
lower(replace(locale, '-', '_')), '_', 1
) AS source_base
FROM (#{eligible_topics_sql}) candidates
),
localization_coverage AS (
SELECT tl.topic_id,
array_agg(
split_part(
lower(replace(tl.locale, '-', '_')), '_', 1
)
) AS bases
FROM topic_localizations tl
GROUP BY tl.topic_id
)
SELECT
COUNT(*)::bigint AS total_count,
COUNT(*) FILTER (
WHERE et.locale IS NOT NULL
AND supported.bases <@ (
COALESCE(lc.bases, ARRAY[]::text[]) || et.source_base
)
)::bigint AS translated_count,
COUNT(*) FILTER (
WHERE et.locale IS NULL
)::bigint AS needs_language_detection_count
FROM eligible_topics et
CROSS JOIN supported
LEFT JOIN localization_coverage lc ON lc.topic_id = et.id
SQL
result = DB.query(sql, supported_locales:).first
{
target_type: "topic",
total_count: result.total_count,
translated_count: result.translated_count,
needs_language_detection_count: result.needs_language_detection_count,
}
end
def self.progress_details
backfill_start_at = DiscourseAi::Translation.backfill_start_at
main_topics =
(
if backfill_start_at
Topic.where("topics.created_at >= ?", backfill_start_at)
else
Topic.none
end
).where(deleted_at: nil)
main_topics =
main_topics.where(
"topics.user_id > 0",
) unless SiteSetting.ai_translation_include_bot_content
category_condition, category_params =
DiscourseAi::Translation.category_scope_condition(category_column: "topics.category_id")
main_topics =
main_topics.where(
"topics.archetype = :pm OR (#{category_condition})",
category_params.merge(pm: Archetype.private_message),
)
case SiteSetting.ai_translation_personal_messages
when "group"
main_topics =
main_topics.where(
"topics.archetype != :pm OR topics.id IN (SELECT topic_id FROM topic_allowed_groups)",
pm: Archetype.private_message,
)
when "none", nil
main_topics = main_topics.where.not(archetype: Archetype.private_message)
end
banner_topics = Topic.where(archetype: Archetype.banner, deleted_at: nil)
eligible_topics_sql =
"(#{main_topics.select("topics.id, topics.locale").to_sql}) UNION " \
"(#{banner_topics.select("topics.id, topics.locale").to_sql})"
supported_locales =
ActiveRecord::Base.connection.quote(SiteSetting.content_localization_supported_locales)
sql = <<~SQL
WITH supported AS MATERIALIZED (
SELECT DISTINCT ON (
split_part(lower(replace(locale, '-', '_')), '_', 1)
)
locale,
split_part(
lower(replace(locale, '-', '_')), '_', 1
) AS base
FROM unnest(string_to_array(#{supported_locales}, '|'))
WITH ORDINALITY configured(locale, position)
ORDER BY split_part(
lower(replace(locale, '-', '_')), '_', 1
),
position
),
eligible_topics AS MATERIALIZED (
SELECT topics.id,
topics.locale,
split_part(
lower(replace(topics.locale, '-', '_')), '_', 1
) AS base
FROM (#{eligible_topics_sql}) topics
),
totals AS (
SELECT COUNT(*)::bigint AS total
FROM eligible_topics
),
source_locale_counts AS (
SELECT base,
COUNT(*)::bigint AS count
FROM eligible_topics
WHERE locale IS NOT NULL
GROUP BY base
),
translated_counts AS (
SELECT supported.base,
COUNT(DISTINCT topics.id)::bigint AS count
FROM eligible_topics topics
JOIN topic_localizations localization
ON localization.topic_id = topics.id
JOIN supported
ON supported.base = split_part(
lower(replace(localization.locale, '-', '_')), '_', 1
)
WHERE topics.locale IS NOT NULL
AND topics.base <> supported.base
GROUP BY supported.base
)
SELECT supported.locale,
COALESCE(translated.count, 0)::bigint AS translated_count,
(
totals.total -
COALESCE(source_locales.count, 0) -
COALESCE(translated.count, 0)
)::bigint AS pending_count,
(
totals.total -
COALESCE(source_locales.count, 0)
)::bigint AS eligible_count
FROM supported
CROSS JOIN totals
LEFT JOIN translated_counts translated
ON translated.base = supported.base
LEFT JOIN source_locale_counts source_locales
ON source_locales.base = supported.base
ORDER BY supported.locale
SQL
{
target_type: "topic",
locales:
DB
.query(sql)
.map do |row|
{
locale: row.locale,
translated_count: row.translated_count,
pending_count: row.pending_count,
eligible_count: row.eligible_count,
}
end,
}
end
def self.needs_localization(limit:)
locales = DiscourseAi::Translation.locales
return [] if locales.blank?
locale_map = {}
locales.each { |l| locale_map[l.split("_").first] ||= l }
target_locale_values = locale_map.map { |base, full| "('#{base}', '#{full}')" }.join(", ")
base_sql = get.where.not(locale: nil).to_sql
sql = <<~SQL
SELECT et.id AS topic_id, target.target_locale
FROM (#{base_sql}) et
JOIN (VALUES #{target_locale_values}) AS target(base_locale, target_locale)
ON target.base_locale != split_part(et.locale, '_', 1)
WHERE NOT EXISTS (
SELECT 1 FROM topic_localizations tl
WHERE tl.topic_id = et.id
AND split_part(tl.locale, '_', 1) = target.base_locale
)
ORDER BY et.updated_at DESC, target.target_locale
LIMIT #{limit.to_i}
SQL
DB.query(sql).map { |r| [r.topic_id, r.target_locale] }
end
private
# all topics that are eligible for translation based on site settings,
# including those without locale detected yet.
def self.get
backfill_start_at = DiscourseAi::Translation.backfill_start_at
topics =
(
if backfill_start_at
Topic.where("topics.created_at >= ?", backfill_start_at)
else
Topic.none
end
).where(deleted_at: nil)
topics =
topics.where("topics.user_id > 0") unless SiteSetting.ai_translation_include_bot_content
pm_scope = SiteSetting.ai_translation_personal_messages
category_condition, category_params =
DiscourseAi::Translation.category_scope_condition(category_column: "topics.category_id")
topics =
topics.where(
"topics.archetype = :pm OR (#{category_condition})",
category_params.merge(pm: Archetype.private_message),
)
# PM scope filter
case pm_scope
when "group"
topics =
topics.where(
"topics.archetype != :pm OR topics.id IN (SELECT topic_id FROM topic_allowed_groups)",
pm: Archetype.private_message,
)
when "none", nil
topics = topics.where.not(archetype: Archetype.private_message)
end
# Always include banner topics regardless of age or category filters
banner_topics = Topic.where(archetype: Archetype.banner, deleted_at: nil)
topics = topics.or(banner_topics)
topics
end
def self.calculate_completion_per_locale(locale)
base_locale = "#{locale.split("_").first}%"
sql = <<~SQL
WITH eligible_topics AS (
#{get.where.not(topics: { locale: nil }).to_sql}
),
total_count AS (
SELECT COUNT(*) AS count FROM eligible_topics
),
done_count AS (
SELECT COUNT(DISTINCT t.id)
FROM eligible_topics t
LEFT JOIN topic_localizations tl ON t.id = tl.topic_id AND tl.locale LIKE :base_locale
WHERE t.locale LIKE :base_locale OR tl.topic_id IS NOT NULL
)
SELECT d.count AS done, t.count AS total
FROM total_count t, done_count d
SQL
done, total = DB.query_single(sql, base_locale:)
{ done:, total: }
end
end
end
end