diff --git a/assets/javascripts/discourse/connectors/topic-above-suggested/related-topics.js b/assets/javascripts/discourse/connectors/topic-above-suggested/related-topics.js new file mode 100644 index 00000000..41f954dd --- /dev/null +++ b/assets/javascripts/discourse/connectors/topic-above-suggested/related-topics.js @@ -0,0 +1,15 @@ +export default { + shouldRender(args) { + return (args.model.related_topics?.length || 0) > 0; + }, + setupComponent(args, component) { + if (component.model.related_topics) { + component.set( + "relatedTopics", + component.model.related_topics.map((topic) => + this.store.createRecord("topic", topic) + ) + ); + } + }, +}; diff --git a/assets/javascripts/discourse/templates/connectors/topic-above-suggested/related-topics.hbs b/assets/javascripts/discourse/templates/connectors/topic-above-suggested/related-topics.hbs new file mode 100644 index 00000000..282be2d7 --- /dev/null +++ b/assets/javascripts/discourse/templates/connectors/topic-above-suggested/related-topics.hbs @@ -0,0 +1,6 @@ +
+ + +
\ No newline at end of file diff --git a/assets/stylesheets/modules/ai-helper/common/ai-helper.scss b/assets/stylesheets/modules/ai-helper/common/ai-helper.scss index bc251d66..463143c0 100644 --- a/assets/stylesheets/modules/ai-helper/common/ai-helper.scss +++ b/assets/stylesheets/modules/ai-helper/common/ai-helper.scss @@ -25,3 +25,7 @@ margin-bottom: 20px; } } + +.topic-above-suggested-outlet.related-topics { + margin: 4.5em 0 1em; +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 818df0dc..8bbd29cd 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1,6 +1,8 @@ en: js: discourse_ai: + related_topics: + title: "Related Topics" ai_helper: title: "Suggest changes using AI" description: "Choose one of the options below, and the AI will suggest you a new version of the text." diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index c46492c5..07e0ed07 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -44,7 +44,7 @@ en: ai_embeddings_models: "Discourse will generate embeddings for each of the models enabled here" ai_embeddings_semantic_suggested_model: "Model to use for suggested topics." ai_embeddings_generate_for_pms: "Generate embeddings for personal messages." - ai_embeddings_semantic_suggested_topics_anons_enabled: "Use Semantic Search for suggested topics for anonymous users." + ai_embeddings_semantic_suggested_topics_enabled: "Use Semantic Search for related topics." ai_embeddings_pg_connection_string: "PostgreSQL connection string for the embeddings module. Needs pgvector extension enabled and a series of tables created. See docs for more info." reviewables: diff --git a/config/settings.yml b/config/settings.yml index 4d7c2136..9e23f304 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -134,5 +134,5 @@ plugins: - multi-qa-mpnet-base-dot-v1 - paraphrase-multilingual-mpnet-base-v2 ai_embeddings_generate_for_pms: false - ai_embeddings_semantic_suggested_topics_anons_enabled: false + ai_embeddings_semantic_suggested_topics_enabled: false ai_embeddings_pg_connection_string: "" diff --git a/lib/modules/embeddings/entry_point.rb b/lib/modules/embeddings/entry_point.rb index 93c4c499..73c8547a 100644 --- a/lib/modules/embeddings/entry_point.rb +++ b/lib/modules/embeddings/entry_point.rb @@ -11,6 +11,43 @@ module DiscourseAi end def inject_into(plugin) + plugin.add_to_class(:topic_view, :related_topics) do + if !@guardian&.user || topic.private_message? || + !SiteSetting.ai_embeddings_semantic_suggested_topics_enabled + return nil + end + + @related_topics ||= + TopicList.new( + :suggested, + nil, + DiscourseAi::Embeddings::SemanticSuggested.candidates_for(topic), + ).topics + end + + plugin.register_modifier( + :topic_view_suggested_topics_options, + ) do |suggested_options, topic_view| + related_topics = topic_view.related_topics + include_random = related_topics.nil? || related_topics.length == 0 + suggested_options.merge(include_random: include_random) + end + + %i[topic_view TopicViewPosts].each do |serializer| + plugin.add_to_serializer(serializer, :related_topics) do + if object.next_page.nil? && !object.topic.private_message? && scope.authenticated? + object.related_topics.map do |t| + SuggestedTopicSerializer.new(t, scope: scope, root: false) + end + end + end + + # custom include method so we also check on semantic search + plugin.add_to_serializer(serializer, :include_related_topics?) do + plugin.enabled? && SiteSetting.ai_embeddings_semantic_suggested_topics_enabled + end + end + callback = Proc.new do |topic| if SiteSetting.ai_embeddings_enabled diff --git a/lib/modules/embeddings/semantic_suggested.rb b/lib/modules/embeddings/semantic_suggested.rb index a170418f..fd01e9ba 100644 --- a/lib/modules/embeddings/semantic_suggested.rb +++ b/lib/modules/embeddings/semantic_suggested.rb @@ -4,10 +4,14 @@ module DiscourseAi module Embeddings class SemanticSuggested def self.build_suggested_topics(topic, pm_params, topic_query) - return unless SiteSetting.ai_embeddings_semantic_suggested_topics_anons_enabled + return unless SiteSetting.ai_embeddings_semantic_suggested_topics_enabled return if topic_query.user return if topic.private_message? + { result: candidates_for(topic), params: {} } + end + + def self.candidates_for(topic) cache_for = case topic.created_at when 6.hour.ago..Time.now @@ -28,19 +32,16 @@ module DiscourseAi rescue StandardError => e Rails.logger.error("SemanticSuggested: #{e}") Jobs.enqueue(:generate_embeddings, topic_id: topic.id) - return { result: [], params: {} } + return ::Topic.none end # array_position forces the order of the topics to be preserved - candidates = - ::Topic - .visible - .listable_topics - .secured - .where(id: candidate_ids) - .order("array_position(ARRAY#{candidate_ids}, id)") - - { result: candidates, params: {} } + ::Topic + .visible + .listable_topics + .secured + .where(id: candidate_ids) + .order("array_position(ARRAY#{candidate_ids}, id)") end def self.search_suggestions(topic) diff --git a/spec/lib/modules/embeddings/semantic_suggested_spec.rb b/spec/lib/modules/embeddings/semantic_suggested_spec.rb index d6f2affd..5e3a525e 100644 --- a/spec/lib/modules/embeddings/semantic_suggested_spec.rb +++ b/spec/lib/modules/embeddings/semantic_suggested_spec.rb @@ -12,7 +12,7 @@ describe DiscourseAi::Embeddings::SemanticSuggested do fab!(:secured_category) { Fabricate(:category, read_restricted: true) } fab!(:secured_category_topic) { Fabricate(:topic, category: secured_category) } - before { SiteSetting.ai_embeddings_semantic_suggested_topics_anons_enabled = true } + before { SiteSetting.ai_embeddings_semantic_suggested_topics_enabled = true } describe "#build_suggested_topics" do before do diff --git a/spec/requests/topic_spec.rb b/spec/requests/topic_spec.rb new file mode 100644 index 00000000..c13d4020 --- /dev/null +++ b/spec/requests/topic_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ::TopicsController do + fab!(:topic) { Fabricate(:topic) } + fab!(:topic1) { Fabricate(:topic) } + fab!(:topic2) { Fabricate(:topic) } + fab!(:user) { Fabricate(:admin) } + + before do + Discourse.cache.clear + SiteSetting.ai_embeddings_semantic_suggested_topics_enabled = true + end + + after { Discourse.cache.clear } + + context "when a user is logged on" do + it "includes related topics in payload when configured" do + DiscourseAi::Embeddings::SemanticSuggested.stubs(:search_suggestions).returns([topic2.id]) + sign_in(user) + + get("#{topic.relative_url}.json") + json = response.parsed_body + + expect(json["suggested_topics"].length).to eq(0) + expect(json["related_topics"].length).to be > 0 + end + end +end