diff --git a/app/controllers/discourse_ai/summarization/summary_controller.rb b/app/controllers/discourse_ai/summarization/summary_controller.rb
new file mode 100644
index 00000000..11ce36a5
--- /dev/null
+++ b/app/controllers/discourse_ai/summarization/summary_controller.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module DiscourseAi
+ module Summarization
+ class SummaryController < ::ApplicationController
+ requires_plugin ::DiscourseAi::PLUGIN_NAME
+ requires_login
+
+ VALID_SINCE_VALUES = [1, 3, 6, 12, 24]
+
+ def chat_channel
+ since = params[:since].to_i
+
+ 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
+
+ RateLimiter.new(
+ current_user,
+ "ai_summarization",
+ 6,
+ SiteSetting.ai_summarization_rate_limit_minutes.minutes,
+ ).performed!
+
+ hijack do
+ summary = DiscourseAi::Summarization::SummaryGenerator.new(chat_channel).summarize!(since)
+
+ render json: { summary: summary }, status: 200
+ end
+ end
+ end
+ end
+end
diff --git a/assets/javascripts/discourse/components/ai-helper.hbs b/assets/javascripts/discourse/components/ai-helper.hbs
index bcec19f8..69c421ca 100644
--- a/assets/javascripts/discourse/components/ai-helper.hbs
+++ b/assets/javascripts/discourse/components/ai-helper.hbs
@@ -62,6 +62,8 @@
/>
{{else}}
-
Select an option...
+ {{i18n
+ "discourse_ai.modals.select_option"
+ }}
{{/if}}
\ No newline at end of file
diff --git a/assets/javascripts/discourse/components/chat-channel-summary.hbs b/assets/javascripts/discourse/components/chat-channel-summary.hbs
new file mode 100644
index 00000000..cf0b0733
--- /dev/null
+++ b/assets/javascripts/discourse/components/chat-channel-summary.hbs
@@ -0,0 +1,23 @@
+
+ {{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
new file mode 100644
index 00000000..0032d4c6
--- /dev/null
+++ b/assets/javascripts/discourse/components/chat-channel-summary.js
@@ -0,0 +1,69 @@
+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/composer-chat-channel-summary.hbs b/assets/javascripts/discourse/templates/modal/composer-chat-channel-summary.hbs
new file mode 100644
index 00000000..b0747efa
--- /dev/null
+++ b/assets/javascripts/discourse/templates/modal/composer-chat-channel-summary.hbs
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/assets/javascripts/initializers/chat-summary.js b/assets/javascripts/initializers/chat-summary.js
new file mode 100644
index 00000000..7fcb5a67
--- /dev/null
+++ b/assets/javascripts/initializers/chat-summary.js
@@ -0,0 +1,42 @@
+import { withPluginApi } from "discourse/lib/plugin-api";
+import showModal from "discourse/lib/show-modal";
+import { action } from "@ember/object";
+
+function initializeChatChannelSummary(api) {
+ const chat = api.container.lookup("service:chat");
+ if (chat) {
+ api.registerChatComposerButton?.({
+ translatedLabel: "discourse_ai.summarization.title",
+ id: "chat_channel_summary",
+ icon: "magic",
+ action: "showChannelSummary",
+ position: "dropdown",
+ });
+
+ api.modifyClass("component:chat-composer", {
+ pluginId: "discourse-ai",
+
+ @action
+ showChannelSummary() {
+ showModal("composer-chat-channel-summary").setProperties({
+ chatChannel: this.chatChannel,
+ });
+ },
+ });
+ }
+}
+
+export default {
+ name: "discourse_ai-chat_channel_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", initializeChatChannelSummary);
+ }
+ },
+};
diff --git a/assets/stylesheets/modules/summarization/common/summarization.scss b/assets/stylesheets/modules/summarization/common/summarization.scss
new file mode 100644
index 00000000..c6fa1bba
--- /dev/null
+++ b/assets/stylesheets/modules/summarization/common/summarization.scss
@@ -0,0 +1,10 @@
+.composer-chat-channel-summary-modal {
+ .summarization-since,
+ .summary-area {
+ margin: 10px 0 10px 0;
+ }
+
+ .summary-area {
+ min-height: 200px;
+ }
+}
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index af639861..826edc39 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -1,6 +1,9 @@
en:
js:
discourse_ai:
+ modals:
+ select_option: "Select an option..."
+
related_topics:
title: "Related Topics"
ai_helper:
@@ -13,6 +16,11 @@ en:
embeddings:
semantic_search: "Topics (Semantic)"
+
+ summarization:
+ title: "Summarize using AI"
+ description: "Select an option below to summarize the conversation sent during the desired timeframe."
+ since: "Last %{hours} hours"
review:
types:
reviewable_ai_post:
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index ce6095fb..01138091 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -50,6 +50,12 @@ en:
ai_embeddings_semantic_search_model: "Model to use for semantic search."
ai_embeddings_semantic_search_enabled: "Enable full-page semantic search."
+ ai_summarization_enabled: "Enable the summarization module."
+ ai_summarization_discourse_service_api_endpoint: "URL where the Discourse summarization API is running."
+ ai_summarization_discourse_service_api_key: "API key for the Discourse summarization API."
+ ai_summarization_model: "Model to use for summarization."
+ ai_summarization_rate_limit_minutes: "Minutes to elapse after the summarization limit is reached (6 requests)."
+
reviewables:
reasons:
flagged_by_toxicity: The AI plugin flagged this after classifying it as toxic.
diff --git a/config/routes.rb b/config/routes.rb
index 89660afc..1d8bc4a4 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,16 +1,18 @@
# frozen_string_literal: true
DiscourseAi::Engine.routes.draw do
- # AI-helper routes
scope module: :ai_helper, path: "/ai-helper", defaults: { format: :json } do
get "prompts" => "assistant#prompts"
post "suggest" => "assistant#suggest"
end
- # Embedding routes
scope module: :embeddings, path: "/embeddings", defaults: { format: :json } do
get "semantic-search" => "embeddings#search"
end
+
+ scope module: :summarization, path: "/summarization", defaults: { format: :json } do
+ post "chat-channel" => "summary#chat_channel"
+ end
end
Discourse::Application.routes.append { mount ::DiscourseAi::Engine, at: "discourse-ai" }
diff --git a/config/settings.yml b/config/settings.yml
index 448f5595..add1a923 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -149,3 +149,20 @@ plugins:
ai_embeddings_semantic_search_enabled:
default: false
client: true
+
+ ai_summarization_enabled:
+ default: true
+ client: true
+ ai_summarization_discourse_service_api_endpoint: ""
+ ai_summarization_discourse_service_api_key: ""
+ ai_summarization_model:
+ type: enum
+ default: "bart-large-cnn-samsum"
+ allow_any: false
+ choices:
+ - bart-large-cnn-samsum
+ - flan-t5-base-samsum
+ - long-t5-tglobal-base-16384-book-summary
+ - gpt-3.5-turbo
+ - gpt-4
+ ai_summarization_rate_limit_minutes: 10
diff --git a/lib/modules/summarization/entry_point.rb b/lib/modules/summarization/entry_point.rb
new file mode 100644
index 00000000..9d18bc25
--- /dev/null
+++ b/lib/modules/summarization/entry_point.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module DiscourseAi
+ module Summarization
+ class EntryPoint
+ def load_files
+ require_relative "summary_generator"
+ end
+
+ def inject_into(plugin)
+ end
+ end
+ end
+end
diff --git a/lib/modules/summarization/summary_generator.rb b/lib/modules/summarization/summary_generator.rb
new file mode 100644
index 00000000..fd9c03d2
--- /dev/null
+++ b/lib/modules/summarization/summary_generator.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+module DiscourseAi
+ module Summarization
+ class SummaryGenerator
+ def initialize(target)
+ @target = target
+ end
+
+ def summarize!(content_since)
+ content = get_content(content_since)
+
+ send("#{summarization_provider}_summarization", content)
+ end
+
+ private
+
+ attr_reader :target
+
+ def summarization_provider
+ model.starts_with?("gpt") ? "openai" : "discourse"
+ end
+
+ def get_content(content_since)
+ case target
+ in Post
+ target.raw
+ in Topic
+ target.posts.order(:post_number).pluck(:raw).join("\n")
+ in ::Chat::Channel
+ target
+ .chat_messages
+ .where("chat_messages.created_at > ?", content_since.hours.ago)
+ .includes(:user)
+ .order(created_at: :asc)
+ .pluck(:username_lower, :message)
+ .map { "#{_1}: #{_2}" }
+ .join("\n")
+ else
+ raise "Invalid target to classify"
+ end
+ end
+
+ def discourse_summarization(content)
+ ::DiscourseAi::Inference::DiscourseClassifier.perform!(
+ "#{SiteSetting.ai_summarization_discourse_service_api_endpoint}/api/v1/classify",
+ model,
+ content,
+ SiteSetting.ai_sentiment_inference_service_api_key,
+ ).dig(:summary_text)
+ end
+
+ def openai_summarization(content)
+ messages = [{ role: "system", content: <<~TEXT }]
+ Summarize the following article:\n\n#{content}
+ TEXT
+
+ ::DiscourseAi::Inference::OpenAiCompletions.perform!(messages, model).dig(
+ :choices,
+ 0,
+ :message,
+ :content,
+ )
+ end
+
+ def model
+ SiteSetting.ai_summarization_model
+ end
+ end
+ end
+end
diff --git a/lib/shared/inference/openai_completions.rb b/lib/shared/inference/openai_completions.rb
index 61f6b3c0..17306a3b 100644
--- a/lib/shared/inference/openai_completions.rb
+++ b/lib/shared/inference/openai_completions.rb
@@ -5,14 +5,12 @@ module ::DiscourseAi
class OpenAiCompletions
CompletionFailed = Class.new(StandardError)
- def self.perform!(messages)
+ def self.perform!(messages, model = SiteSetting.ai_helper_model)
headers = {
"Authorization" => "Bearer #{SiteSetting.ai_openai_api_key}",
"Content-Type" => "application/json",
}
- model = SiteSetting.ai_helper_model
-
connection_opts = { request: { write_timeout: 60, read_timeout: 60, open_timeout: 60 } }
response =
diff --git a/plugin.rb b/plugin.rb
index 456b8903..b2dc70e9 100644
--- a/plugin.rb
+++ b/plugin.rb
@@ -10,6 +10,7 @@
enabled_site_setting :discourse_ai_enabled
register_asset "stylesheets/modules/ai-helper/common/ai-helper.scss"
+register_asset "stylesheets/modules/summarization/common/summarization.scss"
module ::DiscourseAi
PLUGIN_NAME = "discourse-ai"
@@ -34,6 +35,7 @@ after_initialize do
require_relative "lib/modules/sentiment/entry_point"
require_relative "lib/modules/ai_helper/entry_point"
require_relative "lib/modules/embeddings/entry_point"
+ require_relative "lib/modules/summarization/entry_point"
[
DiscourseAi::Embeddings::EntryPoint.new,
@@ -41,6 +43,7 @@ after_initialize do
DiscourseAi::Toxicity::EntryPoint.new,
DiscourseAi::Sentiment::EntryPoint.new,
DiscourseAi::AiHelper::EntryPoint.new,
+ DiscourseAi::Summarization::EntryPoint.new,
].each do |a_module|
a_module.load_files
a_module.inject_into(self)
diff --git a/spec/requests/summarization/summary_controller_spec.rb b/spec/requests/summarization/summary_controller_spec.rb
new file mode 100644
index 00000000..f4fbbcda
--- /dev/null
+++ b/spec/requests/summarization/summary_controller_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+RSpec.describe DiscourseAi::Summarization::SummaryController do
+ describe "#chat_channel" do
+ describe "validating inputs" do
+ it "returns a 404 if there is no chat channel" do
+ post "/disoucrse-ai/summarization/chat-channel", params: { chat_channel_id: 99, since: 3 }
+
+ expect(response.status).to eq(404)
+ end
+
+ it "returns a 400 if the since param is invalid" do
+ chat_channel = Fabricate(:chat_channel)
+
+ post "/disoucrse-ai/summarization/chat-channel",
+ params: {
+ chat_channel_id: chat_channel.id,
+ since: 0,
+ }
+
+ expect(response.status).to eq(404)
+ end
+ end
+ end
+end