FEATURE: Topic summarization (#41)

* FEATURE: Topic summarization

Summarize topics using the TopicView's "summary" filter. The UI is similar to what we do for chat, but we don't allow the user to select a timeframe.


Co-authored-by: Rafael dos Santos Silva <xfalcox@gmail.com>
This commit is contained in:
Roman Rizzi 2023-04-19 16:57:31 -04:00 committed by GitHub
parent 9783e3b025
commit 38e007a3a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 428 additions and 155 deletions

View File

@ -6,19 +6,28 @@ module DiscourseAi
requires_plugin ::DiscourseAi::PLUGIN_NAME requires_plugin ::DiscourseAi::PLUGIN_NAME
requires_login 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 def show
raise PluginDisabled unless SiteSetting.ai_summarization_enabled
target_type = params[:target_type]
raise Discourse::InvalidParameters.new(:target_type) if !VALID_TARGETS.include?(target_type)
since = nil
if target_type == "chat_channel"
since = params[:since].to_i since = params[:since].to_i
raise Discourse::InvalidParameters.new(:since) if !VALID_SINCE_VALUES.include?(since) raise Discourse::InvalidParameters.new(:since) if !VALID_SINCE_VALUES.include?(since)
chat_channel = Chat::Channel.find_by(id: params[:chat_channel_id]) target = Chat::Channel.find_by(id: params[:target_id])
raise Discourse::NotFound.new(:chat_channel) if !chat_channel raise Discourse::NotFound.new(:chat_channel) if !target
raise Discourse::InvalidAccess if !guardian.can_join_chat_channel?(target)
if !(SiteSetting.discourse_ai_enabled && SiteSetting.ai_summarization_enabled) else
raise PluginDisabled 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 end
raise Discourse::InvalidAccess if !guardian.can_join_chat_channel?(chat_channel)
RateLimiter.new( RateLimiter.new(
current_user, current_user,
@ -28,7 +37,8 @@ module DiscourseAi
).performed! ).performed!
hijack do 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 render json: { summary: summary }, status: 200
end end

View File

@ -0,0 +1,32 @@
<DModalBody @title="discourse_ai.summarization.title">
{{#if @allowTimeframe}}
<span>{{i18n "discourse_ai.summarization.description"}}</span>
<ComboBox
@value={{this.sinceHours}}
@content={{this.sinceOptions}}
@onChange={{action this.summarize}}
@valueProperty="value"
@class="summarization-since"
/>
{{/if}}
<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">
{{#unless @allowTimeframe}}
<DButton
@class="btn-primary create"
@action={{this.summarize}}
@label="discourse_ai.summarization.summarize"
/>
{{/unless}}
<DModalCancel @close={{route-action "closeModal"}} />
</div>

View File

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

View File

@ -1,23 +0,0 @@
<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

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

View File

@ -0,0 +1,6 @@
<AiSummary
@targetId={{this.targetId}}
@targetType={{this.targetType}}
@allowTimeframe={{this.allowTimeframe}}
@closeModal={{route-action "closeModal"}}
/>

View File

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

View File

@ -18,8 +18,10 @@ function initializeChatChannelSummary(api) {
@action @action
showChannelSummary() { showChannelSummary() {
showModal("composer-chat-channel-summary").setProperties({ showModal("ai-summary").setProperties({
chatChannel: this.chatChannel, targetId: this.chatChannel.id,
targetType: "chat_channel",
allowTimeframe: true,
}); });
}, },
}); });

View File

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

View File

@ -1,4 +1,4 @@
.composer-chat-channel-summary-modal { .ai-summary-modal {
.summarization-since, .summarization-since,
.summary-area { .summary-area {
margin: 10px 0 10px 0; margin: 10px 0 10px 0;
@ -8,3 +8,7 @@
min-height: 200px; min-height: 200px;
} }
} }
.topic-ai-summarization {
margin-left: 10px;
}

View File

@ -20,7 +20,10 @@ en:
summarization: summarization:
title: "Summarize using AI" title: "Summarize using AI"
description: "Select an option below to summarize the conversation sent during the desired timeframe." 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: review:
types: types:
reviewable_ai_post: reviewable_ai_post:

View File

@ -11,7 +11,7 @@ DiscourseAi::Engine.routes.draw do
end end
scope module: :summarization, path: "/summarization", defaults: { format: :json } do scope module: :summarization, path: "/summarization", defaults: { format: :json } do
post "chat-channel" => "summary#chat_channel" post "summary" => "summary#show"
end end
end end

View File

@ -3,19 +3,20 @@
module DiscourseAi module DiscourseAi
module Summarization module Summarization
class SummaryGenerator class SummaryGenerator
def initialize(target) def initialize(target, user)
@target = target @target = target
@user = user
end end
def summarize!(content_since) def summarize!(content_since)
content = get_content(content_since) content = get_content(content_since)
send("#{summarization_provider}_summarization", content) send("#{summarization_provider}_summarization", content[0..(max_length - 1)])
end end
private private
attr_reader :target attr_reader :target, :user
def summarization_provider def summarization_provider
case model case model
@ -35,7 +36,20 @@ module DiscourseAi
in Post in Post
target.raw target.raw
in Topic 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 in ::Chat::Channel
target target
.chat_messages .chat_messages
@ -46,7 +60,7 @@ module DiscourseAi
.map { "#{_1}: #{_2}" } .map { "#{_1}: #{_2}" }
.join("\n") .join("\n")
else else
raise "Invalid target to classify" raise "Can't find content to summarize"
end end
end end
@ -92,6 +106,19 @@ module DiscourseAi
def model def model
SiteSetting.ai_summarization_model SiteSetting.ai_summarization_model
end 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 end
end end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
RSpec.describe DiscourseAi::Summarization::SummaryController do RSpec.describe DiscourseAi::Summarization::SummaryController do
describe "#chat_channel" do describe "#show" do
fab!(:user) { Fabricate(:user) } fab!(:user) { Fabricate(:user) }
let!(:channel_group) { Fabricate(:group) } let!(:channel_group) { Fabricate(:group) }
let!(:chat_channel) { Fabricate(:private_category_channel, group: channel_group) } let!(:chat_channel) { Fabricate(:private_category_channel, group: channel_group) }
@ -11,20 +11,27 @@ RSpec.describe DiscourseAi::Summarization::SummaryController do
sign_in(user) sign_in(user)
end end
context "when the user can see the channel" do context "when summarizing a chat channel" do
context "if the user can see the channel" do
before { channel_group.add(user) } before { channel_group.add(user) }
describe "validating inputs" do describe "validating inputs" do
it "returns a 404 if there is no chat channel" 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 } 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 end
it "returns a 400 if the since param is invalid" do it "returns a 400 if the since param is invalid" do
post "/discourse-ai/summarization/chat-channel", post "/discourse-ai/summarization/summary",
params: { params: {
chat_channel_id: chat_channel.id, target_type: "chat_channel",
target_id: chat_channel.id,
since: 0, since: 0,
} }
@ -34,9 +41,10 @@ RSpec.describe DiscourseAi::Summarization::SummaryController do
it "returns a 404 when the module is disabled" do it "returns a 404 when the module is disabled" do
SiteSetting.ai_summarization_enabled = false SiteSetting.ai_summarization_enabled = false
post "/discourse-ai/summarization/chat-channel", post "/discourse-ai/summarization/summary",
params: { params: {
chat_channel_id: chat_channel.id, target_type: "chat_channel",
target_id: chat_channel.id,
since: 1, since: 1,
} }
@ -44,13 +52,14 @@ RSpec.describe DiscourseAi::Summarization::SummaryController do
end end
end end
context "when the user can't see the channel" do context "if the user can't see the channel" do
before { channel_group.remove(user) } before { channel_group.remove(user) }
it "returns a 403 if the user can't see the chat channel" do it "returns a 403 if the user can't see the chat channel" do
post "/discourse-ai/summarization/chat-channel", post "/discourse-ai/summarization/summary",
params: { params: {
chat_channel_id: chat_channel.id, target_type: "chat_channel",
target_id: chat_channel.id,
since: 1, since: 1,
} }
@ -59,4 +68,5 @@ RSpec.describe DiscourseAi::Summarization::SummaryController do
end end
end end
end end
end
end end

View File

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

View File

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

View File

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

View File

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