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:
parent
333cb8f212
commit
5549e4d5b3
|
@ -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
|
|
@ -62,6 +62,8 @@
|
||||||
/>
|
/>
|
||||||
<DModalCancel @close={{route-action "closeModal"}} />
|
<DModalCancel @close={{route-action "closeModal"}} />
|
||||||
{{else}}
|
{{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}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
|
@ -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>
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
<ChatChannelSummary
|
||||||
|
@chatChannel={{this.chatChannel}}
|
||||||
|
@closeModal={{route-action "closeModal"}}
|
||||||
|
/>
|
|
@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,10 @@
|
||||||
|
.composer-chat-channel-summary-modal {
|
||||||
|
.summarization-since,
|
||||||
|
.summary-area {
|
||||||
|
margin: 10px 0 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-area {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,9 @@
|
||||||
en:
|
en:
|
||||||
js:
|
js:
|
||||||
discourse_ai:
|
discourse_ai:
|
||||||
|
modals:
|
||||||
|
select_option: "Select an option..."
|
||||||
|
|
||||||
related_topics:
|
related_topics:
|
||||||
title: "Related Topics"
|
title: "Related Topics"
|
||||||
ai_helper:
|
ai_helper:
|
||||||
|
@ -13,6 +16,11 @@ en:
|
||||||
|
|
||||||
embeddings:
|
embeddings:
|
||||||
semantic_search: "Topics (Semantic)"
|
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:
|
review:
|
||||||
types:
|
types:
|
||||||
reviewable_ai_post:
|
reviewable_ai_post:
|
||||||
|
|
|
@ -50,6 +50,12 @@ en:
|
||||||
ai_embeddings_semantic_search_model: "Model to use for semantic search."
|
ai_embeddings_semantic_search_model: "Model to use for semantic search."
|
||||||
ai_embeddings_semantic_search_enabled: "Enable full-page 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:
|
reviewables:
|
||||||
reasons:
|
reasons:
|
||||||
flagged_by_toxicity: The AI plugin flagged this after classifying it as toxic.
|
flagged_by_toxicity: The AI plugin flagged this after classifying it as toxic.
|
||||||
|
|
|
@ -1,16 +1,18 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
DiscourseAi::Engine.routes.draw do
|
DiscourseAi::Engine.routes.draw do
|
||||||
# AI-helper routes
|
|
||||||
scope module: :ai_helper, path: "/ai-helper", defaults: { format: :json } do
|
scope module: :ai_helper, path: "/ai-helper", defaults: { format: :json } do
|
||||||
get "prompts" => "assistant#prompts"
|
get "prompts" => "assistant#prompts"
|
||||||
post "suggest" => "assistant#suggest"
|
post "suggest" => "assistant#suggest"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Embedding routes
|
|
||||||
scope module: :embeddings, path: "/embeddings", defaults: { format: :json } do
|
scope module: :embeddings, path: "/embeddings", defaults: { format: :json } do
|
||||||
get "semantic-search" => "embeddings#search"
|
get "semantic-search" => "embeddings#search"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
scope module: :summarization, path: "/summarization", defaults: { format: :json } do
|
||||||
|
post "chat-channel" => "summary#chat_channel"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Discourse::Application.routes.append { mount ::DiscourseAi::Engine, at: "discourse-ai" }
|
Discourse::Application.routes.append { mount ::DiscourseAi::Engine, at: "discourse-ai" }
|
||||||
|
|
|
@ -149,3 +149,20 @@ plugins:
|
||||||
ai_embeddings_semantic_search_enabled:
|
ai_embeddings_semantic_search_enabled:
|
||||||
default: false
|
default: false
|
||||||
client: true
|
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
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -5,14 +5,12 @@ module ::DiscourseAi
|
||||||
class OpenAiCompletions
|
class OpenAiCompletions
|
||||||
CompletionFailed = Class.new(StandardError)
|
CompletionFailed = Class.new(StandardError)
|
||||||
|
|
||||||
def self.perform!(messages)
|
def self.perform!(messages, model = SiteSetting.ai_helper_model)
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization" => "Bearer #{SiteSetting.ai_openai_api_key}",
|
"Authorization" => "Bearer #{SiteSetting.ai_openai_api_key}",
|
||||||
"Content-Type" => "application/json",
|
"Content-Type" => "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
model = SiteSetting.ai_helper_model
|
|
||||||
|
|
||||||
connection_opts = { request: { write_timeout: 60, read_timeout: 60, open_timeout: 60 } }
|
connection_opts = { request: { write_timeout: 60, read_timeout: 60, open_timeout: 60 } }
|
||||||
|
|
||||||
response =
|
response =
|
||||||
|
|
|
@ -10,6 +10,7 @@
|
||||||
enabled_site_setting :discourse_ai_enabled
|
enabled_site_setting :discourse_ai_enabled
|
||||||
|
|
||||||
register_asset "stylesheets/modules/ai-helper/common/ai-helper.scss"
|
register_asset "stylesheets/modules/ai-helper/common/ai-helper.scss"
|
||||||
|
register_asset "stylesheets/modules/summarization/common/summarization.scss"
|
||||||
|
|
||||||
module ::DiscourseAi
|
module ::DiscourseAi
|
||||||
PLUGIN_NAME = "discourse-ai"
|
PLUGIN_NAME = "discourse-ai"
|
||||||
|
@ -34,6 +35,7 @@ after_initialize do
|
||||||
require_relative "lib/modules/sentiment/entry_point"
|
require_relative "lib/modules/sentiment/entry_point"
|
||||||
require_relative "lib/modules/ai_helper/entry_point"
|
require_relative "lib/modules/ai_helper/entry_point"
|
||||||
require_relative "lib/modules/embeddings/entry_point"
|
require_relative "lib/modules/embeddings/entry_point"
|
||||||
|
require_relative "lib/modules/summarization/entry_point"
|
||||||
|
|
||||||
[
|
[
|
||||||
DiscourseAi::Embeddings::EntryPoint.new,
|
DiscourseAi::Embeddings::EntryPoint.new,
|
||||||
|
@ -41,6 +43,7 @@ after_initialize do
|
||||||
DiscourseAi::Toxicity::EntryPoint.new,
|
DiscourseAi::Toxicity::EntryPoint.new,
|
||||||
DiscourseAi::Sentiment::EntryPoint.new,
|
DiscourseAi::Sentiment::EntryPoint.new,
|
||||||
DiscourseAi::AiHelper::EntryPoint.new,
|
DiscourseAi::AiHelper::EntryPoint.new,
|
||||||
|
DiscourseAi::Summarization::EntryPoint.new,
|
||||||
].each do |a_module|
|
].each do |a_module|
|
||||||
a_module.load_files
|
a_module.load_files
|
||||||
a_module.inject_into(self)
|
a_module.inject_into(self)
|
||||||
|
|
|
@ -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
|
Loading…
Reference in New Issue