mirror of
https://github.com/discourse/discourse.git
synced 2025-06-04 23:14:44 +08:00
FEATURE: Custom content summarization strategies. (#21813)
* FEATURE: Content custom summarization strategies. This PR establishes a pattern for plugins to register alternative ways of summarizing content by extending a class that defines an interface. Core controls which strategy we'll use and who has access to it through the `summarization_strategy` and `custom_summarization_allowed_groups`. It also defines the UI for summarizing topics. Other plugins can access this summarization mechanism and implement their features, removing cross-plugin customizations, as it currently happens between chat and the discourse-ai plugin. * Group membership validation and rate limiting * Work with objects instead of classes * Port summarization feature from discourse-ai to chat * Rename available summaries to 'Top Replies' and 'Summary'
This commit is contained in:
@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::Api::SummariesController < Chat::ApiController
|
||||
VALID_SINCE_VALUES = [1, 3, 6, 12, 24, 72, 168]
|
||||
|
||||
def get_summary
|
||||
since = params[:since].to_i
|
||||
raise Discourse::InvalidParameters.new(:since) if !VALID_SINCE_VALUES.include?(since)
|
||||
|
||||
channel = Chat::Channel.find(params[:channel_id])
|
||||
guardian.ensure_can_join_chat_channel!(channel)
|
||||
|
||||
strategy = Summarization::Base.selected_strategy
|
||||
raise Discourse::NotFound.new unless strategy
|
||||
raise Discourse::InvalidAccess unless strategy.can_request_summaries?(current_user)
|
||||
|
||||
RateLimiter.new(current_user, "channel_summary", 6, 5.minutes).performed!
|
||||
|
||||
hijack do
|
||||
content =
|
||||
channel
|
||||
.chat_messages
|
||||
.where("chat_messages.created_at > ?", since.hours.ago)
|
||||
.includes(:user)
|
||||
.order(created_at: :asc)
|
||||
.pluck(:username_lower, :message)
|
||||
.map { "#{_1}: #{_2}" }
|
||||
.join("\n")
|
||||
|
||||
render json: { summary: strategy.summarize(content) }
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,22 @@
|
||||
<DModalBody @title="chat.summarization.title">
|
||||
<span>{{i18n "chat.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}}
|
||||
<p class="summary-area">{{this.summary}}</p>
|
||||
{{/unless}}
|
||||
</div>
|
||||
|
||||
</DModalBody>
|
||||
|
||||
<div class="modal-footer">
|
||||
<DModalCancel @close={{route-action "closeModal"}} />
|
||||
</div>
|
@ -0,0 +1,65 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { action } from "@ember/object";
|
||||
import I18n from "I18n";
|
||||
|
||||
export default class ChannelSumarry extends Component {
|
||||
@tracked sinceHours = null;
|
||||
@tracked loading = false;
|
||||
@tracked availableSummaries = {};
|
||||
@tracked summary = null;
|
||||
sinceOptions = [
|
||||
{
|
||||
name: I18n.t("chat.summarization.since", { count: 1 }),
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
name: I18n.t("chat.summarization.since", { count: 3 }),
|
||||
value: 3,
|
||||
},
|
||||
{
|
||||
name: I18n.t("chat.summarization.since", { count: 6 }),
|
||||
value: 6,
|
||||
},
|
||||
{
|
||||
name: I18n.t("chat.summarization.since", { count: 12 }),
|
||||
value: 12,
|
||||
},
|
||||
{
|
||||
name: I18n.t("chat.summarization.since", { count: 24 }),
|
||||
value: 24,
|
||||
},
|
||||
{
|
||||
name: I18n.t("chat.summarization.since", { count: 72 }),
|
||||
value: 72,
|
||||
},
|
||||
{
|
||||
name: I18n.t("chat.summarization.since", { count: 168 }),
|
||||
value: 168,
|
||||
},
|
||||
];
|
||||
|
||||
@action
|
||||
summarize(value) {
|
||||
this.loading = true;
|
||||
|
||||
if (this.availableSummaries[value]) {
|
||||
this.summary = this.availableSummaries[value];
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
ajax(`/chat/api/channels/${this.args.channelId}/summarize`, {
|
||||
method: "GET",
|
||||
data: { since: value },
|
||||
})
|
||||
.then((data) => {
|
||||
this.availableSummaries[this.sinceHours] = data.summary;
|
||||
this.summary = this.availableSummaries[this.sinceHours];
|
||||
})
|
||||
.catch(popupAjaxError)
|
||||
.finally(() => (this.loading = false));
|
||||
}
|
||||
}
|
@ -378,6 +378,13 @@ export default class ChatComposer extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
showChannelSummaryModal() {
|
||||
showModal("channel-summary").setProperties({
|
||||
channelId: this.args.channel.id,
|
||||
});
|
||||
}
|
||||
|
||||
#addMentionedUser(userData) {
|
||||
const user = User.create(userData);
|
||||
this.currentMessage.mentionedUsers.set(user.id, user);
|
||||
|
@ -21,6 +21,7 @@ export default {
|
||||
this.chatService = container.lookup("service:chat");
|
||||
this.site = container.lookup("service:site");
|
||||
this.siteSettings = container.lookup("service:site-settings");
|
||||
this.currentUser = container.lookup("service:current-user");
|
||||
this.appEvents = container.lookup("service:app-events");
|
||||
this.appEvents.on("discourse:focus-changed", this, "_handleFocusChanged");
|
||||
|
||||
@ -86,6 +87,28 @@ export default {
|
||||
},
|
||||
});
|
||||
|
||||
const summarizationAllowedGroups =
|
||||
this.siteSettings.custom_summarization_allowed_groups
|
||||
.split("|")
|
||||
.map(parseInt);
|
||||
|
||||
const canSummarize =
|
||||
this.siteSettings.summarization_strategy &&
|
||||
this.currentUser &&
|
||||
this.currentUser.groups.some((g) =>
|
||||
summarizationAllowedGroups.includes(g.id)
|
||||
);
|
||||
|
||||
if (canSummarize) {
|
||||
api.registerChatComposerButton({
|
||||
translatedLabel: "chat.summarization.title",
|
||||
id: "channel-summary",
|
||||
icon: "magic",
|
||||
position: "dropdown",
|
||||
action: "showChannelSummaryModal",
|
||||
});
|
||||
}
|
||||
|
||||
// we want to decorate the chat quote dates regardless
|
||||
// of whether the current user has chat enabled
|
||||
api.decorateCookedElement(
|
||||
|
@ -0,0 +1,4 @@
|
||||
<ChannelSummary
|
||||
@channelId={{this.channelId}}
|
||||
@closeModal={{route-action "closeModal"}}
|
||||
/>
|
@ -0,0 +1,10 @@
|
||||
.channel-summary-modal {
|
||||
.summarization-since,
|
||||
.summary-area {
|
||||
margin: 10px 0 10px 0;
|
||||
}
|
||||
|
||||
.summary-area {
|
||||
min-height: 50px;
|
||||
}
|
||||
}
|
@ -56,3 +56,4 @@
|
||||
@import "chat-thread-header";
|
||||
@import "chat-thread-list-header";
|
||||
@import "chat-thread-unread-indicator";
|
||||
@import "channel-summary-modal";
|
||||
|
@ -114,6 +114,14 @@ en:
|
||||
heading: "Chat"
|
||||
join: "Join"
|
||||
last_visit: "last visit"
|
||||
|
||||
summarization:
|
||||
title: "Summarize messages"
|
||||
description: "Select an option below to summarize the conversation sent during the desired timeframe."
|
||||
summarize: "Summarize"
|
||||
since:
|
||||
one: "Last hour"
|
||||
other: "Last %{count} hours"
|
||||
mention_warning:
|
||||
dismiss: "dismiss"
|
||||
cannot_see: "%{username} can't access this channel and was not notified."
|
||||
|
@ -35,6 +35,8 @@ Chat::Engine.routes.draw do
|
||||
|
||||
put "/channels/:channel_id/messages/:message_id/restore" => "channel_messages#restore"
|
||||
delete "/channels/:channel_id/messages/:message_id" => "channel_messages#destroy"
|
||||
|
||||
get "/channels/:channel_id/summarize" => "summaries#get_summary"
|
||||
end
|
||||
|
||||
# direct_messages_controller routes
|
||||
|
@ -0,0 +1,32 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe Chat::Api::SummariesController do
|
||||
fab!(:current_user) { Fabricate(:user) }
|
||||
fab!(:group) { Fabricate(:group) }
|
||||
let(:plugin) { Plugin::Instance.new }
|
||||
|
||||
before do
|
||||
group.add(current_user)
|
||||
|
||||
strategy = DummyCustomSummarization.new("dummy")
|
||||
plugin.register_summarization_strategy(strategy)
|
||||
SiteSetting.summarization_strategy = strategy.model
|
||||
SiteSetting.custom_summarization_allowed_groups = group.id
|
||||
|
||||
SiteSetting.chat_enabled = true
|
||||
SiteSetting.chat_allowed_groups = group.id
|
||||
sign_in(current_user)
|
||||
end
|
||||
|
||||
describe "#get_summary" do
|
||||
context "when the user is not allowed to join the channel" do
|
||||
fab!(:channel) { Fabricate(:private_category_channel) }
|
||||
|
||||
it "returns a 403" do
|
||||
get "/chat/api/channels/#{channel.id}/summarize", params: { since: 6 }
|
||||
|
||||
expect(response.status).to eq(403)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
41
plugins/chat/spec/system/chat_summarization_spec.rb
Normal file
41
plugins/chat/spec/system/chat_summarization_spec.rb
Normal file
@ -0,0 +1,41 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe "Summarize a channel since your last visit", type: :system, js: true do
|
||||
fab!(:current_user) { Fabricate(:user) }
|
||||
fab!(:group) { Fabricate(:group) }
|
||||
let(:plugin) { Plugin::Instance.new }
|
||||
|
||||
fab!(:channel) { Fabricate(:chat_channel) }
|
||||
|
||||
fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel) }
|
||||
|
||||
let(:chat) { PageObjects::Pages::Chat.new }
|
||||
|
||||
before do
|
||||
group.add(current_user)
|
||||
|
||||
strategy = DummyCustomSummarization.new("dummy")
|
||||
plugin.register_summarization_strategy(strategy)
|
||||
SiteSetting.summarization_strategy = strategy.model
|
||||
SiteSetting.custom_summarization_allowed_groups = group.id.to_s
|
||||
|
||||
SiteSetting.chat_enabled = true
|
||||
SiteSetting.chat_allowed_groups = group.id.to_s
|
||||
sign_in(current_user)
|
||||
chat_system_bootstrap(current_user, [channel])
|
||||
end
|
||||
|
||||
it "displays a summary of the messages since the selected timeframe" do
|
||||
chat.visit_channel(channel)
|
||||
|
||||
find(".chat-composer-dropdown__trigger-btn").click
|
||||
find(".chat-composer-dropdown__action-btn.channel-summary").click
|
||||
|
||||
expect(page.has_css?(".channel-summary-modal", wait: 5)).to eq(true)
|
||||
|
||||
find(".summarization-since").click
|
||||
find(".select-kit-row[data-value=\"3\"]").click
|
||||
|
||||
expect(find(".summary-area").text).to eq(DummyCustomSummarization::RESPONSE)
|
||||
end
|
||||
end
|
Reference in New Issue
Block a user