diff --git a/.env-example b/.env-example index 3efc530ff4..7a0fb0c573 100644 --- a/.env-example +++ b/.env-example @@ -57,3 +57,6 @@ GEOCODER_UNITS=km # Units for geocoder results (e.g., km or m # DECIDIM_AI_BASIC_AUTH=":" Required for the AI Request Handler # DECIDIM_AI_REPORTING_USER_EMAIL="" # DECIDIM_AI_SECRET="" # Not required for the AI Request Handler + +# Machine translation +DEEPL_AUTH_KEY=your_deepl_api_key_here diff --git a/Gemfile b/Gemfile index fa826d191b..769d265704 100644 --- a/Gemfile +++ b/Gemfile @@ -11,6 +11,7 @@ gem "decidim-initiatives", github: "decidim/decidim", tag: DECIDIM_TAG gem "decidim-templates", github: "decidim/decidim", tag: DECIDIM_TAG gem "bootsnap", "~> 1.4", require: false +gem "deepl-rb", "~> 3.2" gem "puma", ">= 6.3.1" gem "activerecord-postgis-adapter", "~> 8.0", ">= 8.0.3" diff --git a/Gemfile.lock b/Gemfile.lock index 6fa684936d..4584be2882 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -491,6 +491,7 @@ GEM declarative-builder (0.2.0) trailblazer-option (~> 0.1.0) declarative-option (0.1.0) + deepl-rb (3.2.0) deface (1.9.0) actionview (>= 5.2) nokogiri (>= 1.6) @@ -1131,6 +1132,7 @@ DEPENDENCIES decidim-survey_multiple_answers! decidim-templates! decidim-term_customizer! + deepl-rb (~> 3.2) deface dotenv-rails (~> 2.7) faker (~> 3.2) diff --git a/app/services/deepl_translator.rb b/app/services/deepl_translator.rb new file mode 100644 index 0000000000..2775b687e6 --- /dev/null +++ b/app/services/deepl_translator.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "deepl" + +class DeeplTranslator + attr_reader :resource, :field_name, :text, :target_locale, :source_locale + + def initialize(resource, field_name, text, target_locale, source_locale) + @resource = resource + @field_name = field_name + @text = text + @target_locale = target_locale + @source_locale = source_locale + end + + def translate + return if text.blank? + + translation = ::DeepL.translate text, source_locale.to_s, target_locale.to_s + return nil if translation.nil? || translation.text.blank? + + Decidim::MachineTranslationSaveJob.perform_later( + resource, + field_name, + target_locale, + translation.text + ) + rescue StandardError => e + Rails.logger.error("[DeeplTranslator] #{e.class} - #{e.message}") + nil + end +end diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 83c3742c04..82966551b1 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -79,10 +79,8 @@ search: # translation: # # Get an API key and set billing info at https://code.google.com/apis/console to use Google Translate # api_key: "AbC-dEf5" - translation: deepl_api_key: <%= ENV["DEEPL_API_KEY"] %> - # Do not consider these keys missing: ignore_missing: - decidim.admin.assembly_copies.new.select @@ -104,6 +102,8 @@ ignore_missing: # Consider these keys used: ignore_unused: + - activemodel.attributes.organization.enable_machine_translations + - activemodel.attributes.organization.enable_machine_translations - faker.* - decidim.admin.models.assembly.fields.* - decidim.events.proposals.author_confirmation_proposal_event.* diff --git a/config/initializers/decidim.rb b/config/initializers/decidim.rb index a75842b136..1be3cf11f3 100644 --- a/config/initializers/decidim.rb +++ b/config/initializers/decidim.rb @@ -335,7 +335,7 @@ # for more information about how it works and how to set it up. # # Enable machine translations - config.enable_machine_translations = false + config.enable_machine_translations = true # # If you want to enable machine translation you can create your own service # to interact with third party service to translate the user content. @@ -360,7 +360,8 @@ # end # end # - config.machine_translation_service = "Decidim::Dev::DummyTranslator" + config.machine_translation_service = "DeeplTranslator" + config.machine_translation_delay = 0.seconds # Defines the social networking services used for social sharing config.social_share_services = Rails.application.secrets.decidim[:social_share_services] diff --git a/config/locales/en.yml b/config/locales/en.yml index 089cae034d..be34996125 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -4,6 +4,8 @@ en: attributes: assembly: copy_landing_page_blocks: Copy landing page blocks + organization: + enable_machine_translations: Enable machine translations participatory_process: copy_landing_page_blocks: Copy landing page blocks date: diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 64d0b8997f..51e457d774 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -4,6 +4,8 @@ fr: attributes: assembly: copy_landing_page_blocks: Copier les blocs de la page d'accueil + organization: + enable_machine_translations: Activer la traduction automatique participatory_process: copy_landing_page_blocks: Copier les blocs de la page d'accueil date: diff --git a/spec/services/deepl_translator_spec.rb b/spec/services/deepl_translator_spec.rb new file mode 100644 index 0000000000..47fbb11af6 --- /dev/null +++ b/spec/services/deepl_translator_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "spec_helper" +require "deepl" + +module Decidim + describe DeeplTranslator do + let(:title) { { en: "New Title" } } + let(:process) { build(:participatory_process, title:) } + let(:target_locale) { "fr" } + let(:source_locale) { "en" } + let(:translation) { double("translation", text: "Nouveau Titre") } + + before do + allow(Decidim).to receive(:machine_translation_service_klass).and_return(DeeplTranslator) + allow(::DeepL).to receive(:translate).with(title[source_locale.to_sym], source_locale, target_locale).and_return(translation) + end + + describe "When fields job is executed" do + before { clear_enqueued_jobs } + + it "calls DeeplTranslator to create machine translations" do + expect(DeeplTranslator).to receive(:new).with( + process, + "title", + process["title"][source_locale], + target_locale, + source_locale + ).and_call_original + + process.save + + MachineTranslationFieldsJob.perform_now( + process, + "title", + process["title"][source_locale], + target_locale, + source_locale + ) + end + end + + describe "#translate" do + subject { DeeplTranslator.new(process, "title", text, target_locale, source_locale).translate } + let(:text) { title[source_locale.to_sym] } + + context "when translation is nil" do + before { allow(::DeepL).to receive(:translate).and_return(nil) } + + it "does not enqueue a job" do + expect(Decidim::MachineTranslationSaveJob).not_to receive(:perform_later) + expect(subject).to be_nil + end + end + + context "when text is empty" do + let(:text) { "" } + + it "does not enqueue a job" do + expect(Decidim::MachineTranslationSaveJob).not_to receive(:perform_later) + expect(subject).to be_nil + end + end + + context "when DeepL raises an error" do + before { allow(::DeepL).to receive(:translate).and_raise(StandardError, "API failure") } + + it "logs the error and flow does not break" do + expect(Rails.logger).to receive(:error).with(/\[DeeplTranslator\] StandardError - API failure/) + expect(subject).to be_nil + end + end + end + end +end