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"}} />
|
||||
{{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>
|
|
@ -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:
|
||||
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:
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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" }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
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 =
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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