diff --git a/app/controllers/discourse_ai/summarization/summary_controller.rb b/app/controllers/discourse_ai/summarization/summary_controller.rb
index 023d91c8..11677f62 100644
--- a/app/controllers/discourse_ai/summarization/summary_controller.rb
+++ b/app/controllers/discourse_ai/summarization/summary_controller.rb
@@ -6,19 +6,28 @@ module DiscourseAi
requires_plugin ::DiscourseAi::PLUGIN_NAME
requires_login
- VALID_SINCE_VALUES = [1, 3, 6, 12, 24]
+ VALID_SINCE_VALUES = [1, 3, 6, 12, 24, 72, 168]
+ VALID_TARGETS = %w[chat_channel topic]
- def chat_channel
- since = params[:since].to_i
+ def show
+ raise PluginDisabled unless SiteSetting.ai_summarization_enabled
+ target_type = params[:target_type]
- raise Discourse::InvalidParameters.new(:since) if !VALID_SINCE_VALUES.include?(since)
- chat_channel = Chat::Channel.find_by(id: params[:chat_channel_id])
- raise Discourse::NotFound.new(:chat_channel) if !chat_channel
+ raise Discourse::InvalidParameters.new(:target_type) if !VALID_TARGETS.include?(target_type)
- if !(SiteSetting.discourse_ai_enabled && SiteSetting.ai_summarization_enabled)
- raise PluginDisabled
+ since = nil
+
+ if target_type == "chat_channel"
+ since = params[:since].to_i
+ raise Discourse::InvalidParameters.new(:since) if !VALID_SINCE_VALUES.include?(since)
+ target = Chat::Channel.find_by(id: params[:target_id])
+ raise Discourse::NotFound.new(:chat_channel) if !target
+ raise Discourse::InvalidAccess if !guardian.can_join_chat_channel?(target)
+ else
+ target = Topic.find_by(id: params[:target_id])
+ raise Discourse::NotFound.new(:topic) if !target
+ raise Discourse::InvalidAccess if !guardian.can_see_topic?(target)
end
- raise Discourse::InvalidAccess if !guardian.can_join_chat_channel?(chat_channel)
RateLimiter.new(
current_user,
@@ -28,7 +37,8 @@ module DiscourseAi
).performed!
hijack do
- summary = DiscourseAi::Summarization::SummaryGenerator.new(chat_channel).summarize!(since)
+ summary =
+ DiscourseAi::Summarization::SummaryGenerator.new(target, current_user).summarize!(since)
render json: { summary: summary }, status: 200
end
diff --git a/assets/javascripts/discourse/components/ai-summary.hbs b/assets/javascripts/discourse/components/ai-summary.hbs
new file mode 100644
index 00000000..d6902b01
--- /dev/null
+++ b/assets/javascripts/discourse/components/ai-summary.hbs
@@ -0,0 +1,32 @@
+
+ {{#if @allowTimeframe}}
+ {{i18n "discourse_ai.summarization.description"}}
+
+ {{/if}}
+
+
+
+
+ {{#unless this.loading}}
+
+ {{/unless}}
+
+
+
+
+
\ No newline at end of file
diff --git a/assets/javascripts/discourse/components/ai-summary.js b/assets/javascripts/discourse/components/ai-summary.js
new file mode 100644
index 00000000..da00c497
--- /dev/null
+++ b/assets/javascripts/discourse/components/ai-summary.js
@@ -0,0 +1,83 @@
+import Component from "@glimmer/component";
+import { tracked } from "@glimmer/tracking";
+import { action } from "@ember/object";
+import { ajax } from "discourse/lib/ajax";
+import { popupAjaxError } from "discourse/lib/ajax-error";
+import I18n from "I18n";
+
+export default class AiSummary extends Component {
+ @tracked sinceHours = null;
+ @tracked loading = false;
+ @tracked availableSummaries = {};
+ @tracked summary = null;
+ sinceOptions = [
+ {
+ name: I18n.t("discourse_ai.summarization.since", { count: 1 }),
+ value: 1,
+ },
+ {
+ name: I18n.t("discourse_ai.summarization.since", { count: 3 }),
+ value: 3,
+ },
+ {
+ name: I18n.t("discourse_ai.summarization.since", { count: 6 }),
+ value: 6,
+ },
+ {
+ name: I18n.t("discourse_ai.summarization.since", { count: 12 }),
+ value: 12,
+ },
+ {
+ name: I18n.t("discourse_ai.summarization.since", { count: 24 }),
+ value: 24,
+ },
+ {
+ name: I18n.t("discourse_ai.summarization.since", { count: 72 }),
+ value: 72,
+ },
+ {
+ name: I18n.t("discourse_ai.summarization.since", { count: 168 }),
+ value: 168,
+ },
+ ];
+
+ get canSummarize() {
+ return (!this.args.allowTimeframe || this.sinceHours) && !this.loading;
+ }
+
+ @action
+ summarize(value) {
+ this.loading = true;
+ const attrs = {
+ target_id: this.args.targetId,
+ target_type: this.args.targetType,
+ };
+
+ if (this.args.allowTimeframe) {
+ this.sinceHours = value;
+
+ if (this.availableSummaries[this.sinceHours]) {
+ this.summary = this.availableSummaries[this.sinceHours];
+ this.loading = false;
+ return;
+ } else {
+ attrs.since = this.sinceHours;
+ }
+ }
+
+ ajax("/discourse-ai/summarization/summary", {
+ method: "POST",
+ data: attrs,
+ })
+ .then((data) => {
+ if (this.args.allowTimeframe) {
+ this.availableSummaries[this.sinceHours] = data.summary;
+ this.summary = this.availableSummaries[this.sinceHours];
+ } else {
+ this.summary = data.summary;
+ }
+ })
+ .catch(popupAjaxError)
+ .finally(() => (this.loading = false));
+ }
+}
diff --git a/assets/javascripts/discourse/components/chat-channel-summary.hbs b/assets/javascripts/discourse/components/chat-channel-summary.hbs
deleted file mode 100644
index cf0b0733..00000000
--- a/assets/javascripts/discourse/components/chat-channel-summary.hbs
+++ /dev/null
@@ -1,23 +0,0 @@
-
- {{i18n "discourse_ai.summarization.description"}}
-
-
-
-
-
- {{#unless this.loading}}
-
- {{/unless}}
-
-
-
-
-
\ No newline at end of file
diff --git a/assets/javascripts/discourse/components/chat-channel-summary.js b/assets/javascripts/discourse/components/chat-channel-summary.js
deleted file mode 100644
index 0032d4c6..00000000
--- a/assets/javascripts/discourse/components/chat-channel-summary.js
+++ /dev/null
@@ -1,69 +0,0 @@
-import Component from "@glimmer/component";
-import { tracked } from "@glimmer/tracking";
-import { action } from "@ember/object";
-import { ajax } from "discourse/lib/ajax";
-import { popupAjaxError } from "discourse/lib/ajax-error";
-import I18n from "I18n";
-
-export default class ChatChannelSummary extends Component {
- @tracked sinceHours = null;
- @tracked loading = false;
- @tracked availableSummaries = {};
- @tracked summary = null;
- sinceOptions = [
- {
- name: I18n.t("discourse_ai.summarization.since", { hours: "1" }),
- value: 1,
- },
- {
- name: I18n.t("discourse_ai.summarization.since", { hours: "3" }),
- value: 3,
- },
- {
- name: I18n.t("discourse_ai.summarization.since", { hours: "6" }),
- value: 6,
- },
- {
- name: I18n.t("discourse_ai.summarization.since", { hours: "12" }),
- value: 12,
- },
- {
- name: I18n.t("discourse_ai.summarization.since", { hours: "24" }),
- value: 24,
- },
- ];
-
- get modalTitle() {
- return I18n.t("discourse_ai.summarization.modal_title", {
- channel_title: this.args.chatChannel.escapedTitle,
- });
- }
-
- get canSummarize() {
- return this.sinceHours && !this.loading;
- }
-
- @action
- summarize(value) {
- this.sinceHours = value;
- this.loading = true;
- const chatChannelId = this.args.chatChannel.id;
-
- if (this.availableSummaries[this.sinceHours]) {
- this.summary = this.availableSummaries[this.sinceHours];
- this.loading = false;
- return;
- }
-
- ajax("/discourse-ai/summarization/chat-channel", {
- method: "POST",
- data: { chat_channel_id: chatChannelId, since: this.sinceHours },
- })
- .then((data) => {
- this.availableSummaries[this.sinceHours] = data.summary;
- this.summary = this.availableSummaries[this.sinceHours];
- })
- .catch(popupAjaxError)
- .finally(() => (this.loading = false));
- }
-}
diff --git a/assets/javascripts/discourse/templates/modal/ai-summary.hbs b/assets/javascripts/discourse/templates/modal/ai-summary.hbs
new file mode 100644
index 00000000..15b9273d
--- /dev/null
+++ b/assets/javascripts/discourse/templates/modal/ai-summary.hbs
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/assets/javascripts/discourse/templates/modal/composer-chat-channel-summary.hbs b/assets/javascripts/discourse/templates/modal/composer-chat-channel-summary.hbs
deleted file mode 100644
index b0747efa..00000000
--- a/assets/javascripts/discourse/templates/modal/composer-chat-channel-summary.hbs
+++ /dev/null
@@ -1,4 +0,0 @@
-
\ No newline at end of file
diff --git a/assets/javascripts/initializers/chat-summary.js b/assets/javascripts/initializers/chat-summary.js
index 7fcb5a67..7496c5ce 100644
--- a/assets/javascripts/initializers/chat-summary.js
+++ b/assets/javascripts/initializers/chat-summary.js
@@ -18,8 +18,10 @@ function initializeChatChannelSummary(api) {
@action
showChannelSummary() {
- showModal("composer-chat-channel-summary").setProperties({
- chatChannel: this.chatChannel,
+ showModal("ai-summary").setProperties({
+ targetId: this.chatChannel.id,
+ targetType: "chat_channel",
+ allowTimeframe: true,
});
},
});
diff --git a/assets/javascripts/initializers/topic-summary.js b/assets/javascripts/initializers/topic-summary.js
new file mode 100644
index 00000000..bcfa33f0
--- /dev/null
+++ b/assets/javascripts/initializers/topic-summary.js
@@ -0,0 +1,43 @@
+import { withPluginApi } from "discourse/lib/plugin-api";
+import showModal from "discourse/lib/show-modal";
+
+function initializeTopicSummary(api) {
+ api.modifyClass("component:scrolling-post-stream", {
+ showAiSummary() {
+ showModal("ai-summary").setProperties({
+ targetId: this.posts["posts"][0].topic_id,
+ targetType: "topic",
+ allowTimeframe: false,
+ });
+ },
+ });
+
+ api.addTopicSummaryCallback((html, attrs, widget) => {
+ html.push(
+ widget.attach("button", {
+ className: "btn btn-primary topic-ai-summarization",
+ icon: "magic",
+ title: "discourse_ai.summarization.title",
+ label: "discourse_ai.summarization.title",
+ action: "showAiSummary",
+ })
+ );
+
+ return html;
+ });
+}
+
+export default {
+ name: "discourse_ai-topic_summary",
+
+ initialize(container) {
+ const settings = container.lookup("site-settings:main");
+
+ const summarizationEnabled =
+ settings.discourse_ai_enabled && settings.ai_summarization_enabled;
+
+ if (summarizationEnabled) {
+ withPluginApi("1.6.0", initializeTopicSummary);
+ }
+ },
+};
diff --git a/assets/stylesheets/modules/summarization/common/summarization.scss b/assets/stylesheets/modules/summarization/common/summarization.scss
index c6fa1bba..b34ee0f2 100644
--- a/assets/stylesheets/modules/summarization/common/summarization.scss
+++ b/assets/stylesheets/modules/summarization/common/summarization.scss
@@ -1,4 +1,4 @@
-.composer-chat-channel-summary-modal {
+.ai-summary-modal {
.summarization-since,
.summary-area {
margin: 10px 0 10px 0;
@@ -8,3 +8,7 @@
min-height: 200px;
}
}
+
+.topic-ai-summarization {
+ margin-left: 10px;
+}
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 826edc39..42f0e946 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -20,7 +20,10 @@ en:
summarization:
title: "Summarize using AI"
description: "Select an option below to summarize the conversation sent during the desired timeframe."
- since: "Last %{hours} hours"
+ summarize: "Summarize"
+ since:
+ one: "Last hour"
+ other: "Last %{count} hours"
review:
types:
reviewable_ai_post:
diff --git a/config/routes.rb b/config/routes.rb
index 1d8bc4a4..097e659a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -11,7 +11,7 @@ DiscourseAi::Engine.routes.draw do
end
scope module: :summarization, path: "/summarization", defaults: { format: :json } do
- post "chat-channel" => "summary#chat_channel"
+ post "summary" => "summary#show"
end
end
diff --git a/lib/modules/summarization/summary_generator.rb b/lib/modules/summarization/summary_generator.rb
index cce7ecb9..2ec1f7ac 100644
--- a/lib/modules/summarization/summary_generator.rb
+++ b/lib/modules/summarization/summary_generator.rb
@@ -3,19 +3,20 @@
module DiscourseAi
module Summarization
class SummaryGenerator
- def initialize(target)
+ def initialize(target, user)
@target = target
+ @user = user
end
def summarize!(content_since)
content = get_content(content_since)
- send("#{summarization_provider}_summarization", content)
+ send("#{summarization_provider}_summarization", content[0..(max_length - 1)])
end
private
- attr_reader :target
+ attr_reader :target, :user
def summarization_provider
case model
@@ -35,7 +36,20 @@ module DiscourseAi
in Post
target.raw
in Topic
- target.posts.order(:post_number).pluck(:raw).join("\n")
+ TopicView
+ .new(
+ target,
+ user,
+ {
+ filter: "summary",
+ exclude_deleted_users: true,
+ exclude_hidden: true,
+ show_deleted: false,
+ },
+ )
+ .posts
+ .pluck(:raw)
+ .join("\n")
in ::Chat::Channel
target
.chat_messages
@@ -46,7 +60,7 @@ module DiscourseAi
.map { "#{_1}: #{_2}" }
.join("\n")
else
- raise "Invalid target to classify"
+ raise "Can't find content to summarize"
end
end
@@ -92,6 +106,19 @@ module DiscourseAi
def model
SiteSetting.ai_summarization_model
end
+
+ def max_length
+ lengths = {
+ "bart-large-cnn-samsum" => 8192,
+ "flan-t5-base-samsum" => 8192,
+ "long-t5-tglobal-base-16384-book-summary" => 8192,
+ "gpt-3.5-turbo" => 8192,
+ "gpt-4" => 8192,
+ "claude-v1" => 8192,
+ }
+
+ lengths[model]
+ end
end
end
end
diff --git a/spec/requests/summarization/summary_controller_spec.rb b/spec/requests/summarization/summary_controller_spec.rb
index b3c6b735..609be193 100644
--- a/spec/requests/summarization/summary_controller_spec.rb
+++ b/spec/requests/summarization/summary_controller_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
RSpec.describe DiscourseAi::Summarization::SummaryController do
- describe "#chat_channel" do
+ describe "#show" do
fab!(:user) { Fabricate(:user) }
let!(:channel_group) { Fabricate(:group) }
let!(:chat_channel) { Fabricate(:private_category_channel, group: channel_group) }
@@ -11,50 +11,60 @@ RSpec.describe DiscourseAi::Summarization::SummaryController do
sign_in(user)
end
- context "when the user can see the channel" do
- before { channel_group.add(user) }
+ context "when summarizing a chat channel" do
+ context "if the user can see the channel" do
+ before { channel_group.add(user) }
- describe "validating inputs" do
- it "returns a 404 if there is no chat channel" do
- post "/discourse-ai/summarization/chat-channel", params: { chat_channel_id: 99, since: 3 }
+ describe "validating inputs" do
+ it "returns a 404 if there is no chat channel" do
+ post "/discourse-ai/summarization/summary",
+ params: {
+ target_type: "chat_channel",
+ target_id: 99,
+ since: 3,
+ }
- expect(response.status).to eq(404)
+ expect(response.status).to eq(404)
+ end
+
+ it "returns a 400 if the since param is invalid" do
+ post "/discourse-ai/summarization/summary",
+ params: {
+ target_type: "chat_channel",
+ target_id: chat_channel.id,
+ since: 0,
+ }
+
+ expect(response.status).to eq(400)
+ end
+
+ it "returns a 404 when the module is disabled" do
+ SiteSetting.ai_summarization_enabled = false
+
+ post "/discourse-ai/summarization/summary",
+ params: {
+ target_type: "chat_channel",
+ target_id: chat_channel.id,
+ since: 1,
+ }
+
+ expect(response.status).to eq(404)
+ end
end
- it "returns a 400 if the since param is invalid" do
- post "/discourse-ai/summarization/chat-channel",
- params: {
- chat_channel_id: chat_channel.id,
- since: 0,
- }
+ context "if the user can't see the channel" do
+ before { channel_group.remove(user) }
- expect(response.status).to eq(400)
- end
+ it "returns a 403 if the user can't see the chat channel" do
+ post "/discourse-ai/summarization/summary",
+ params: {
+ target_type: "chat_channel",
+ target_id: chat_channel.id,
+ since: 1,
+ }
- it "returns a 404 when the module is disabled" do
- SiteSetting.ai_summarization_enabled = false
-
- post "/discourse-ai/summarization/chat-channel",
- params: {
- chat_channel_id: chat_channel.id,
- since: 1,
- }
-
- expect(response.status).to eq(404)
- end
- end
-
- context "when the user can't see the channel" do
- before { channel_group.remove(user) }
-
- it "returns a 403 if the user can't see the chat channel" do
- post "/discourse-ai/summarization/chat-channel",
- params: {
- chat_channel_id: chat_channel.id,
- since: 1,
- }
-
- expect(response.status).to eq(403)
+ expect(response.status).to eq(403)
+ end
end
end
end
diff --git a/spec/support/summarization_stubs.rb b/spec/support/summarization_stubs.rb
new file mode 100644
index 00000000..c46359b8
--- /dev/null
+++ b/spec/support/summarization_stubs.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+class SummarizationStubs
+ class << self
+ def test_summary
+ "This is a summary"
+ end
+
+ def openai_response(content)
+ {
+ id: "chatcmpl-6sZfAb30Rnv9Q7ufzFwvQsMpjZh8S",
+ object: "chat.completion",
+ created: 1_678_464_820,
+ model: "gpt-3.5-turbo-0301",
+ usage: {
+ prompt_tokens: 337,
+ completion_tokens: 162,
+ total_tokens: 499,
+ },
+ choices: [
+ { message: { role: "assistant", content: content }, finish_reason: "stop", index: 0 },
+ ],
+ }
+ end
+
+ def openai_chat_summarization_stub(chat_messages)
+ prompt_messages =
+ chat_messages
+ .sort_by(&:created_at)
+ .map { |m| "#{m.user.username_lower}: #{m.message}" }
+ .join("\n")
+
+ summary_prompt = [{ role: "system", content: <<~TEXT }]
+ Summarize the following article:\n\n#{prompt_messages}
+ TEXT
+
+ WebMock
+ .stub_request(:post, "https://api.openai.com/v1/chat/completions")
+ .with(body: { model: "gpt-4", messages: summary_prompt }.to_json)
+ .to_return(status: 200, body: JSON.dump(openai_response(test_summary)))
+ end
+
+ def openai_topic_summarization_stub(topic, user)
+ prompt_posts = TopicView.new(topic, user, { filter: "summary" }).posts.map(&:raw).join("\n")
+
+ summary_prompt = [{ role: "system", content: <<~TEXT }]
+ Summarize the following article:\n\n#{prompt_posts}
+ TEXT
+
+ WebMock
+ .stub_request(:post, "https://api.openai.com/v1/chat/completions")
+ .with(body: { model: "gpt-4", messages: summary_prompt }.to_json)
+ .to_return(status: 200, body: JSON.dump(openai_response(test_summary)))
+ end
+ end
+end
diff --git a/spec/system/page_objects/modals/summarization.rb b/spec/system/page_objects/modals/summarization.rb
new file mode 100644
index 00000000..8ab036cb
--- /dev/null
+++ b/spec/system/page_objects/modals/summarization.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module PageObjects
+ module Modals
+ class Summarization < PageObjects::Modals::Base
+ def visible?
+ page.has_css?(".ai-summary-modal", wait: 5)
+ end
+
+ def select_timeframe(option)
+ find(".summarization-since").click
+ find(".select-kit-row[data-value=\"#{option}\"]").click
+ end
+
+ def summary_value
+ find(".summary-area").value
+ end
+
+ def generate_summary
+ find(".ai-summary-modal .create").click
+ end
+ end
+ end
+end
diff --git a/spec/system/summarization/chat_channel_summarization_spec.rb b/spec/system/summarization/chat_channel_summarization_spec.rb
new file mode 100644
index 00000000..7e0a0fa5
--- /dev/null
+++ b/spec/system/summarization/chat_channel_summarization_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require_relative "../../support/summarization_stubs"
+
+RSpec.describe "AI chat channel summarization", type: :system, js: true do
+ fab!(:user) { Fabricate(:leader) }
+ fab!(:channel) { Fabricate(:chat_channel) }
+
+ fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel) }
+ fab!(:message_2) { Fabricate(:chat_message, chat_channel: channel) }
+ fab!(:message_3) { Fabricate(:chat_message, chat_channel: channel) }
+
+ before do
+ sign_in(user)
+ chat_system_bootstrap(user, [channel])
+ SiteSetting.ai_summarization_enabled = true
+ SiteSetting.ai_summarization_model = "gpt-4"
+ end
+
+ let(:summarization_modal) { PageObjects::Modals::Summarization.new }
+
+ it "returns a summary using the selected timeframe" do
+ visit("/chat/c/-/#{channel.id}")
+
+ SummarizationStubs.openai_chat_summarization_stub([message_1, message_2, message_3])
+
+ find(".chat-composer-dropdown__trigger-btn").click
+ find(".chat-composer-dropdown__action-btn.chat_channel_summary").click
+
+ expect(summarization_modal).to be_visible
+
+ summarization_modal.select_timeframe("3")
+
+ expect(summarization_modal.summary_value).to eq(SummarizationStubs.test_summary)
+ end
+end
diff --git a/spec/system/summarization/topic_summarization_spec.rb b/spec/system/summarization/topic_summarization_spec.rb
new file mode 100644
index 00000000..df1c706c
--- /dev/null
+++ b/spec/system/summarization/topic_summarization_spec.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require_relative "../../support/summarization_stubs"
+
+RSpec.describe "AI chat channel summarization", type: :system, js: true do
+ fab!(:user) { Fabricate(:leader) }
+ fab!(:topic) { Fabricate(:topic, has_summary: true) }
+
+ fab!(:post_1) { Fabricate(:post, topic: topic) }
+ fab!(:post_2) { Fabricate(:post, topic: topic) }
+
+ before do
+ sign_in(user)
+ SiteSetting.ai_summarization_enabled = true
+ SiteSetting.ai_summarization_model = "gpt-4"
+ end
+
+ let(:summarization_modal) { PageObjects::Modals::Summarization.new }
+
+ it "returns a summary using the selected timeframe" do
+ visit("/t/-/#{topic.id}")
+
+ SummarizationStubs.openai_topic_summarization_stub(topic, user)
+
+ find(".topic-ai-summarization").click
+
+ expect(summarization_modal).to be_visible
+
+ summarization_modal.generate_summary
+
+ expect(summarization_modal.summary_value).to eq(SummarizationStubs.test_summary)
+ end
+end