FEATURE: Chat channel summarization. (#32)

* start summary module

* chat channel summarization

* FEATURE: modal for channel summarization

---------

Co-authored-by: Roman Rizzi <rizziromanalejandro@gmail.com>
This commit is contained in:
Rafael dos Santos Silva 2023-04-04 11:24:09 -03:00 committed by GitHub
parent 333cb8f212
commit 5549e4d5b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 333 additions and 6 deletions

View File

@ -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

View File

@ -62,6 +62,8 @@
/>
<DModalCancel @close={{route-action "closeModal"}} />
{{else}}
<div class="ai-helper-waiting-selection">Select an option...</div>
<div class="ai-helper-waiting-selection">{{i18n
"discourse_ai.modals.select_option"
}}</div>
{{/if}}
</div>

View File

@ -0,0 +1,23 @@
<DModalBody @title="discourse_ai.summarization.title">
<span>{{i18n "discourse_ai.summarization.description"}}</span>
<ComboBox
@value={{this.sinceHours}}
@content={{this.sinceOptions}}
@onChange={{action this.summarize}}
@valueProperty="value"
@class="summarization-since"
/>
<div class="channel-summary">
<ConditionalLoadingSpinner @condition={{this.loading}} />
{{#unless this.loading}}
<Textarea @value={{this.summary}} disabled="true" class="summary-area" />
{{/unless}}
</div>
</DModalBody>
<div class="modal-footer">
<DModalCancel @close={{route-action "closeModal"}} />
</div>

View File

@ -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));
}
}

View File

@ -0,0 +1,4 @@
<ChatChannelSummary
@chatChannel={{this.chatChannel}}
@closeModal={{route-action "closeModal"}}
/>

View File

@ -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);
}
},
};

View File

@ -0,0 +1,10 @@
.composer-chat-channel-summary-modal {
.summarization-since,
.summary-area {
margin: 10px 0 10px 0;
}
.summary-area {
min-height: 200px;
}
}

View File

@ -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:

View File

@ -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.

View File

@ -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" }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 =

View File

@ -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)

View File

@ -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