Skip to content

Commit d5a70a7

Browse files
committed
FEATURE: Add discourse-ai as a translation provider
1 parent 352493d commit d5a70a7

File tree

8 files changed

+232
-2
lines changed

8 files changed

+232
-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: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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+
# we don't need that much text to determine the locale
18+
text = get_text(topic_or_post).truncate(MAX_DETECT_LOCALE_TEXT_LENGTH)
19+
20+
get_ai_helper_output(
21+
text,
22+
CompletionPrompt.find_by(id: CompletionPrompt::DETECT_TEXT_LOCALE),
23+
)
24+
end
25+
end
26+
27+
def self.translate(topic_or_post)
28+
return unless required_settings_enabled
29+
30+
detected_lang = detect(topic_or_post)
31+
translated_text =
32+
from_custom_fields(topic_or_post) do
33+
get_ai_helper_output(
34+
get_text(topic_or_post),
35+
CompletionPrompt.find_by(id: CompletionPrompt::TRANSLATE),
36+
)
37+
end
38+
39+
[detected_lang, translated_text]
40+
end
41+
42+
private
43+
44+
def self.get_ai_helper_output(text, prompt)
45+
::DiscourseAi::AiHelper::Assistant.new.generate_and_send_prompt(
46+
prompt,
47+
text,
48+
Discourse.system_user,
49+
)[
50+
:suggestions
51+
].first
52+
end
53+
54+
def self.required_settings_enabled
55+
SiteSetting.translator_enabled && SiteSetting.translator == "DiscourseAi" &&
56+
SiteSetting.discourse_ai_enabled && SiteSetting.ai_helper_enabled
57+
end
58+
end
59+
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?(DiscourseAutomation)
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?(DiscourseAutomation)
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: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
describe ::DiscourseTranslator::TranslatorSelectionValidator do
6+
subject { described_class.new }
7+
8+
fab!(:llm_model) { Fabricate(:llm_model) }
9+
10+
describe "#valid_value?" do
11+
context "when value is blank" do
12+
it "returns true" do
13+
expect(subject.valid_value?(nil)).to eq(true)
14+
expect(subject.valid_value?("")).to eq(true)
15+
end
16+
end
17+
18+
context "when value is 'DiscourseAi'" do
19+
context "when DiscourseAutomation is not defined" do
20+
it "returns false" do
21+
hide_const("DiscourseAutomation")
22+
expect(subject.valid_value?("DiscourseAi")).to eq(false)
23+
end
24+
end
25+
26+
context "when DiscourseAutomation is defined but ai_helper_enabled is false" do
27+
it "returns false" do
28+
SiteSetting.ai_helper_enabled = false
29+
expect(subject.valid_value?("DiscourseAi")).to eq(false)
30+
end
31+
end
32+
33+
context "when DiscourseAutomation is defined and ai_helper_enabled is true" do
34+
it "returns true" do
35+
DiscourseAi::Completions::Llm.with_prepared_responses(["OK"]) do
36+
SiteSetting.ai_helper_model = "custom:#{llm_model.id}"
37+
SiteSetting.ai_helper_enabled = true
38+
end
39+
expect(subject.valid_value?("DiscourseAi")).to eq(true)
40+
end
41+
end
42+
end
43+
44+
context "when value is not 'DiscourseAi'" do
45+
it "returns true" do
46+
expect(subject.valid_value?("googly")).to eq(true)
47+
expect(subject.valid_value?("poopy")).to eq(true)
48+
end
49+
end
50+
end
51+
52+
describe "#error_message" do
53+
context "when DiscourseAutomation is not defined" do
54+
it "returns the not_installed error message" do
55+
hide_const("DiscourseAutomation")
56+
expect(subject.error_message).to eq(I18n.t("translator.discourse_ai.not_installed"))
57+
end
58+
end
59+
60+
context "when DiscourseAutomation 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(subject.error_message).to eq(I18n.t("translator.discourse_ai.ai_helper_required"))
64+
end
65+
end
66+
67+
context "when DiscourseAutomation is defined and ai_helper_enabled is true" do
68+
it "returns nil" do
69+
DiscourseAi::Completions::Llm.with_prepared_responses(["OK"]) do
70+
SiteSetting.ai_helper_model = "custom:#{llm_model.id}"
71+
SiteSetting.ai_helper_enabled = true
72+
end
73+
expect(subject.error_message).to be_nil
74+
end
75+
end
76+
end
77+
end

spec/services/discourse_ai_spec.rb

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
describe DiscourseTranslator::DiscourseAi do
6+
before do
7+
SiteSetting.translator_enabled = true
8+
Fabricate(:fake_model).tap do |fake_llm|
9+
SiteSetting.public_send("ai_helper_model=", "custom:#{fake_llm.id}")
10+
end
11+
end
12+
13+
describe ".detect" do
14+
let(:post) { Fabricate(:post) }
15+
16+
it "stores the detected language in a custom field" do
17+
locale = "de"
18+
DiscourseAi::Completions::Llm.with_prepared_responses(["<output>de</output>"]) do
19+
DiscourseTranslator::DiscourseAi.detect(post)
20+
end
21+
22+
expect(post.custom_fields[DiscourseTranslator::DETECTED_LANG_CUSTOM_FIELD]).to eq locale
23+
end
24+
25+
it "truncates to MAX LENGTH" do
26+
truncated_text = post.cooked.truncate(DiscourseTranslator::DiscourseAi::MAX_DETECT_LOCALE_TEXT_LENGTH)
27+
expect_any_instance_of(::DiscourseAi::AiHelper::Assistant)
28+
.to receive(:generate_and_send_prompt)
29+
.with(
30+
CompletionPrompt.find_by(id: CompletionPrompt::DETECT_TEXT_LOCALE),
31+
truncated_text,
32+
Discourse.system_user
33+
).and_call_original
34+
35+
DiscourseAi::Completions::Llm.with_prepared_responses(["<output>de</output>"]) do
36+
DiscourseTranslator::DiscourseAi.detect(post)
37+
end
38+
end
39+
40+
it "returns if settings are not correct" do
41+
42+
end
43+
end
44+
45+
describe ".translate" do
46+
it "translates the post and returns [locale, translated_text]" do
47+
post = Fabricate(:post)
48+
DiscourseAi::Completions::Llm.with_prepared_responses(["<output>some translated text</output>", "<output>translated</output>"]) do
49+
locale, translated_text = DiscourseTranslator::DiscourseAi.translate(post)
50+
expect(locale).to eq "de"
51+
expect(translated_text).to eq "some translated text"
52+
end
53+
end
54+
end
55+
end

0 commit comments

Comments
 (0)