Skip to content

Commit 7c2fe57

Browse files
authored
FEATURE: Add DiscourseAi translator (#181)
This commit adds DiscourseAi as a provider for translation. It defines two DiscourseAi::Completions::Prompt, in translator and language_detector, that uses the set ai_helper_model. Validations are in place when this new provider is selected but its requirements are not set up (SiteSetting.ai_helper_model, SiteSetting.ai_enabled)
1 parent df35f4a commit 7c2fe57

File tree

12 files changed

+403
-2
lines changed

12 files changed

+403
-2
lines changed

about.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"tests": {
3+
"requiredPlugins": [
4+
"https://github.com/discourse/discourse-ai"
5+
]
6+
}
7+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseAi
4+
class LanguageDetector
5+
PROMPT_TEXT = <<~TEXT
6+
I want you to act as a language expert, determining the locale for a set of text.
7+
The locale is a language identifier, such as "en" for English, "de" for German, etc,
8+
and can also include a region identifier, such as "en-GB" for British English, or "zh-Hans" for Simplified Chinese.
9+
I will provide you with text, and you will determine the locale of the text.
10+
Include your locale between <language></language> XML tags.
11+
TEXT
12+
13+
def initialize(text)
14+
@text = text
15+
end
16+
17+
def detect
18+
prompt =
19+
DiscourseAi::Completions::Prompt.new(
20+
PROMPT_TEXT,
21+
messages: [{ type: :user, content: @text, id: "user" }],
22+
)
23+
24+
response =
25+
DiscourseAi::Completions::Llm.proxy(SiteSetting.ai_helper_model).generate(
26+
prompt,
27+
user: Discourse.system_user,
28+
feature_name: "translator-language-detect",
29+
)
30+
31+
(Nokogiri::HTML5.fragment(response).at("language")&.text || response)
32+
end
33+
end
34+
end
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseAi
4+
class Translator
5+
PROMPT_TEMPLATE = <<~TEXT.freeze
6+
You are a highly skilled linguist and web programmer, with expertise in many languages, and very well versed in HTML.
7+
Your task is to identify the language of the text I provide and accurately translate it into this language locale "%{target_language}" while preserving the meaning, tone, and nuance of the original text.
8+
The text will contain html tags, which must absolutely be preserved in the translation.
9+
Maintain proper grammar, spelling, and punctuation in the translated version.
10+
Wrap the translated text in a <translation> tag.
11+
TEXT
12+
13+
def initialize(text, target_language)
14+
@text = text
15+
@target_language = target_language
16+
end
17+
18+
def translate
19+
prompt =
20+
DiscourseAi::Completions::Prompt.new(
21+
build_prompt(@target_language),
22+
messages: [{ type: :user, content: @text, id: "user" }],
23+
)
24+
25+
llm_translation =
26+
DiscourseAi::Completions::Llm.proxy(SiteSetting.ai_helper_model).generate(
27+
prompt,
28+
user: Discourse.system_user,
29+
feature_name: "translator-translate",
30+
)
31+
32+
(Nokogiri::HTML5.fragment(llm_translation).at("translation")&.inner_html || llm_translation)
33+
end
34+
35+
private
36+
37+
def build_prompt(target_language)
38+
PROMPT_TEMPLATE % { target_language: target_language }
39+
end
40+
end
41+
end
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "base"
4+
require "json"
5+
6+
module DiscourseTranslator
7+
class DiscourseAi < Base
8+
MAX_DETECT_LOCALE_TEXT_LENGTH = 1000
9+
def self.language_supported?(_)
10+
true
11+
end
12+
13+
def self.detect(topic_or_post)
14+
return unless required_settings_enabled
15+
16+
topic_or_post.custom_fields[DiscourseTranslator::DETECTED_LANG_CUSTOM_FIELD] ||= begin
17+
::DiscourseAi::LanguageDetector.new(text_for_detection(topic_or_post)).detect
18+
end
19+
rescue => e
20+
Rails.logger.warn(
21+
"#{::DiscourseTranslator::PLUGIN_NAME}: Failed to detect language for #{topic_or_post.class.name} #{topic_or_post.id}: #{e}",
22+
)
23+
end
24+
25+
def self.translate(topic_or_post)
26+
return unless required_settings_enabled
27+
28+
detected_lang = detect(topic_or_post)
29+
translated_text =
30+
from_custom_fields(topic_or_post) do
31+
::DiscourseAi::Translator.new(text_for_translation(topic_or_post), I18n.locale).translate
32+
end
33+
34+
[detected_lang, translated_text]
35+
end
36+
37+
private
38+
39+
def self.required_settings_enabled
40+
SiteSetting.translator_enabled && SiteSetting.translator == "DiscourseAi" &&
41+
SiteSetting.discourse_ai_enabled && SiteSetting.ai_helper_enabled
42+
end
43+
end
44+
end

config/locales/server.en.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ en:
2828
microsoft:
2929
missing_token: "The translator was unable to retrieve a valid token."
3030
missing_key: "No Azure Subscription Key provided."
31+
32+
discourse_ai:
33+
not_installed: "You need to install the discourse-ai plugin to use this feature."
34+
ai_helper_required: 'You need to <a href="%{base_url}/admin/site_settings/category/all_results?filter=plugin%3Adiscourse-ai%20helper">configure the ai helper</a> to use this feature.'
3135
not_in_group:
3236
user_not_in_group: "You don't belong to a group allowed to translate."
3337
poster_not_in_group: "Post wasn't made by an user in an allowed group."

config/settings.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ discourse_translator:
77
client: true
88
type: enum
99
choices:
10+
- DiscourseAi
1011
- Microsoft
1112
- Google
1213
- Amazon
1314
- Yandex
1415
- LibreTranslate
16+
validator: "DiscourseTranslator::TranslatorSelectionValidator"
1517
translator_azure_subscription_key:
1618
default: ''
1719
translator_azure_region:
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseTranslator
4+
class TranslatorSelectionValidator
5+
def initialize(opts = {})
6+
@opts = opts
7+
end
8+
9+
def valid_value?(val)
10+
return true if val.blank?
11+
12+
if val == "DiscourseAi"
13+
return false if !defined?(::DiscourseAi)
14+
return false if !SiteSetting.ai_helper_enabled
15+
end
16+
17+
true
18+
end
19+
20+
def error_message
21+
return I18n.t("translator.discourse_ai.not_installed") if !defined?(::DiscourseAi)
22+
23+
I18n.t("translator.discourse_ai.ai_helper_required") if !SiteSetting.ai_helper_enabled
24+
end
25+
end
26+
end

plugin.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# frozen_string_literal: true
22

33
# name: discourse-translator
4-
# about: Translates posts on Discourse using Microsoft, Google, Yandex or LibreTranslate translation APIs.
4+
# about: Translates posts on Discourse using Microsoft, Google, Yandex, LibreTranslate, or Discourse AI translation APIs.
55
# meta_topic_id: 32630
66
# version: 0.3.0
77
# authors: Alan Tan
@@ -31,7 +31,7 @@ module ::DiscourseTranslator
3131

3232
topic_view_post_custom_fields_allowlister { [::DiscourseTranslator::DETECTED_LANG_CUSTOM_FIELD] }
3333

34-
reloadable_patch do |plugin|
34+
reloadable_patch do
3535
Guardian.prepend(DiscourseTranslator::GuardianExtension)
3636
Post.prepend(DiscourseTranslator::PostExtension)
3737
Topic.prepend(DiscourseTranslator::TopicExtension)
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
describe ::DiscourseTranslator::TranslatorSelectionValidator do
6+
fab!(:llm_model)
7+
8+
describe "#valid_value?" do
9+
context "when value is blank" do
10+
it "returns true" do
11+
expect(described_class.new.valid_value?(nil)).to eq(true)
12+
expect(described_class.new.valid_value?("")).to eq(true)
13+
end
14+
end
15+
16+
context "when value is 'DiscourseAi'" do
17+
context "when DiscourseAi is not defined" do
18+
it "returns false" do
19+
hide_const("DiscourseAi")
20+
expect(described_class.new.valid_value?("DiscourseAi")).to eq(false)
21+
end
22+
end
23+
24+
context "when DiscourseAi is defined but ai_helper_enabled is false" do
25+
it "returns false" do
26+
SiteSetting.ai_helper_enabled = false
27+
expect(described_class.new.valid_value?("DiscourseAi")).to eq(false)
28+
end
29+
end
30+
31+
context "when DiscourseAi is defined and ai_helper_enabled is true" do
32+
it "returns true" do
33+
DiscourseAi::Completions::Llm.with_prepared_responses(["OK"]) do
34+
SiteSetting.ai_helper_model = "custom:#{llm_model.id}"
35+
SiteSetting.ai_helper_enabled = true
36+
end
37+
expect(described_class.new.valid_value?("DiscourseAi")).to eq(true)
38+
end
39+
end
40+
end
41+
42+
context "when value is not 'DiscourseAi'" do
43+
it "returns true" do
44+
expect(described_class.new.valid_value?("googly")).to eq(true)
45+
expect(described_class.new.valid_value?("poopy")).to eq(true)
46+
end
47+
end
48+
end
49+
50+
describe "#error_message" do
51+
context "when DiscourseAi is not defined" do
52+
it "returns the not_installed error message" do
53+
hide_const("DiscourseAi")
54+
expect(described_class.new.error_message).to eq(
55+
I18n.t("translator.discourse_ai.not_installed"),
56+
)
57+
end
58+
end
59+
60+
context "when DiscourseAi is defined but ai_helper_enabled is false" do
61+
it "returns the ai_helper_required error message" do
62+
SiteSetting.ai_helper_enabled = false
63+
expect(described_class.new.error_message).to eq(
64+
I18n.t("translator.discourse_ai.ai_helper_required"),
65+
)
66+
end
67+
end
68+
69+
context "when DiscourseAi is defined and ai_helper_enabled is true" do
70+
it "returns nil" do
71+
DiscourseAi::Completions::Llm.with_prepared_responses(["OK"]) do
72+
SiteSetting.ai_helper_model = "custom:#{llm_model.id}"
73+
SiteSetting.ai_helper_enabled = true
74+
end
75+
expect(described_class.new.error_message).to be_nil
76+
end
77+
end
78+
end
79+
end
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
describe DiscourseAi::LanguageDetector do
6+
before do
7+
Fabricate(:fake_model).tap do |fake_llm|
8+
SiteSetting.public_send("ai_helper_model=", "custom:#{fake_llm.id}")
9+
end
10+
SiteSetting.ai_helper_enabled = true
11+
end
12+
13+
describe ".detect" do
14+
it "creates the correct prompt" do
15+
allow(DiscourseAi::Completions::Prompt).to receive(:new).with(
16+
DiscourseAi::LanguageDetector::PROMPT_TEXT,
17+
messages: [{ type: :user, content: "meow", id: "user" }],
18+
).and_call_original
19+
20+
described_class.new("meow").detect
21+
end
22+
23+
it "sends the language detection prompt to the ai helper model" do
24+
mock_prompt = instance_double(DiscourseAi::Completions::Prompt)
25+
mock_llm = instance_double(DiscourseAi::Completions::Llm)
26+
27+
allow(DiscourseAi::Completions::Prompt).to receive(:new).and_return(mock_prompt)
28+
allow(DiscourseAi::Completions::Llm).to receive(:proxy).with(
29+
SiteSetting.ai_helper_model,
30+
).and_return(mock_llm)
31+
allow(mock_llm).to receive(:generate).with(
32+
mock_prompt,
33+
user: Discourse.system_user,
34+
feature_name: "translator-language-detect",
35+
)
36+
37+
described_class.new("meow").detect
38+
end
39+
40+
it "returns the language from the llm's response in the language tag" do
41+
DiscourseAi::Completions::Llm.with_prepared_responses(["<language>de</language>"]) do
42+
expect(described_class.new("meow").detect).to eq "de"
43+
end
44+
end
45+
end
46+
end

0 commit comments

Comments
 (0)