mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06: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:
parent
dcceb91000
commit
8938ecabc2
@ -8,6 +8,7 @@ import offsetCalculator from "discourse/lib/offset-calculator";
|
|||||||
import { inject as service } from "@ember/service";
|
import { inject as service } from "@ember/service";
|
||||||
import { bind } from "discourse-common/utils/decorators";
|
import { bind } from "discourse-common/utils/decorators";
|
||||||
import domUtils from "discourse-common/utils/dom-utils";
|
import domUtils from "discourse-common/utils/dom-utils";
|
||||||
|
import showModal from "discourse/lib/show-modal";
|
||||||
|
|
||||||
const DEBOUNCE_DELAY = 50;
|
const DEBOUNCE_DELAY = 50;
|
||||||
|
|
||||||
@ -268,6 +269,12 @@ export default MountWidget.extend({
|
|||||||
this.screenTrack.setOnscreen(onscreenPostNumbers, readPostNumbers);
|
this.screenTrack.setOnscreen(onscreenPostNumbers, readPostNumbers);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
showSummary() {
|
||||||
|
showModal("topic-summary").setProperties({
|
||||||
|
topicId: this.posts["posts"][0].topic_id,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
_scrollTriggered() {
|
_scrollTriggered() {
|
||||||
scheduleOnce("afterRender", this, this.scrolled);
|
scheduleOnce("afterRender", this, this.scrolled);
|
||||||
},
|
},
|
||||||
|
@ -0,0 +1,14 @@
|
|||||||
|
<DModalBody @title="summary.strategy.title">
|
||||||
|
<div class="topic-summary" {{did-insert this.summarize}}>
|
||||||
|
<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,25 @@
|
|||||||
|
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 { schedule } from "@ember/runloop";
|
||||||
|
|
||||||
|
export default class TopicSummary extends Component {
|
||||||
|
@tracked loading = false;
|
||||||
|
@tracked summary = null;
|
||||||
|
|
||||||
|
@action
|
||||||
|
summarize() {
|
||||||
|
schedule("afterRender", () => {
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
ajax(`/t/${this.args.topicId}/strategy-summary`)
|
||||||
|
.then((data) => {
|
||||||
|
this.summary = data.summary;
|
||||||
|
})
|
||||||
|
.catch(popupAjaxError)
|
||||||
|
.finally(() => (this.loading = false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -15,7 +15,7 @@
|
|||||||
@mobileView={{@mobileView}}
|
@mobileView={{@mobileView}}
|
||||||
@toggleMultiSelect={{@toggleMultiSelect}}
|
@toggleMultiSelect={{@toggleMultiSelect}}
|
||||||
@showTopicSlowModeUpdate={{@showTopicSlowModeUpdate}}
|
@showTopicSlowModeUpdate={{@showTopicSlowModeUpdate}}
|
||||||
@showSummary={{@showSummary}}
|
@showTopReplies={{@showTopReplies}}
|
||||||
@deleteTopic={{@deleteTopic}}
|
@deleteTopic={{@deleteTopic}}
|
||||||
@recoverTopic={{@recoverTopic}}
|
@recoverTopic={{@recoverTopic}}
|
||||||
@toggleClosed={{@toggleClosed}}
|
@toggleClosed={{@toggleClosed}}
|
||||||
|
@ -119,7 +119,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="show-summary btn btn-small"
|
class="show-summary btn btn-small"
|
||||||
title={{i18n "summary.short_title"}}
|
title={{i18n "summary.short_title"}}
|
||||||
{{on "click" @showSummary}}
|
{{on "click" @showTopReplies}}
|
||||||
>
|
>
|
||||||
{{d-icon "layer-group"}}
|
{{d-icon "layer-group"}}
|
||||||
{{i18n "summary.short_label"}}
|
{{i18n "summary.short_label"}}
|
||||||
|
@ -520,9 +520,9 @@ export default Controller.extend(bufferedProperty("model"), {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
showSummary() {
|
showTopReplies() {
|
||||||
return this.get("model.postStream")
|
return this.get("model.postStream")
|
||||||
.showSummary()
|
.showTopReplies()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.updateQueryParams();
|
this.updateQueryParams();
|
||||||
});
|
});
|
||||||
|
@ -68,7 +68,6 @@ import {
|
|||||||
import { addTagsHtmlCallback } from "discourse/lib/render-tags";
|
import { addTagsHtmlCallback } from "discourse/lib/render-tags";
|
||||||
import { addToolbarCallback } from "discourse/components/d-editor";
|
import { addToolbarCallback } from "discourse/components/d-editor";
|
||||||
import { addTopicParticipantClassesCallback } from "discourse/widgets/topic-map";
|
import { addTopicParticipantClassesCallback } from "discourse/widgets/topic-map";
|
||||||
import { addTopicSummaryCallback } from "discourse/widgets/toggle-topic-summary";
|
|
||||||
import { addTopicTitleDecorator } from "discourse/components/topic-title";
|
import { addTopicTitleDecorator } from "discourse/components/topic-title";
|
||||||
import { addUserMenuProfileTabItem } from "discourse/components/user-menu/profile-tab-content";
|
import { addUserMenuProfileTabItem } from "discourse/components/user-menu/profile-tab-content";
|
||||||
import { addUsernameSelectorDecorator } from "discourse/helpers/decorate-username-selector";
|
import { addUsernameSelectorDecorator } from "discourse/helpers/decorate-username-selector";
|
||||||
@ -1047,28 +1046,6 @@ class PluginApi {
|
|||||||
addTopicParticipantClassesCallback(callback);
|
addTopicParticipantClassesCallback(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* EXPERIMENTAL. Do not use.
|
|
||||||
* Adds a callback to be topic summary widget markup that can be used, for example,
|
|
||||||
* to add an extra button to the topic summary widget.
|
|
||||||
*
|
|
||||||
* Example:
|
|
||||||
*
|
|
||||||
* api.addTopicSummaryCallback((html, attrs, widget) => {
|
|
||||||
* html.push(
|
|
||||||
* widget.attach("button", {
|
|
||||||
* className: "btn btn-primary",
|
|
||||||
* icon: "magic",
|
|
||||||
* title: "discourse_ai.ai_helper.title",
|
|
||||||
* label: "discourse_ai.ai_helper.title",
|
|
||||||
* action: "showAiSummary",
|
|
||||||
* })
|
|
||||||
* );
|
|
||||||
**/
|
|
||||||
addTopicSummaryCallback(callback) {
|
|
||||||
addTopicSummaryCallback(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* Adds a callback to be executed on the "transformed" post that is passed to the post
|
* Adds a callback to be executed on the "transformed" post that is passed to the post
|
||||||
|
@ -218,7 +218,7 @@ export default function transformPost(
|
|||||||
postAtts.userFilters = postStream.userFilters;
|
postAtts.userFilters = postStream.userFilters;
|
||||||
postAtts.topicSummaryEnabled = postStream.summary;
|
postAtts.topicSummaryEnabled = postStream.summary;
|
||||||
postAtts.topicWordCount = topic.word_count;
|
postAtts.topicWordCount = topic.word_count;
|
||||||
postAtts.hasTopicSummary = topic.has_summary;
|
postAtts.hasTopRepliesSummary = topic.has_summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (postAtts.isDeleted) {
|
if (postAtts.isDeleted) {
|
||||||
|
@ -251,7 +251,7 @@ export default RestModel.extend({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
showSummary() {
|
showTopReplies() {
|
||||||
this.cancelFilter();
|
this.cancelFilter();
|
||||||
this.set("filter", "summary");
|
this.set("filter", "summary");
|
||||||
return this.refreshAndJumpToSecondVisible();
|
return this.refreshAndJumpToSecondVisible();
|
||||||
|
@ -0,0 +1,4 @@
|
|||||||
|
<TopicSummary
|
||||||
|
@topicId={{this.topicId}}
|
||||||
|
@closeModal={{route-action "closeModal"}}
|
||||||
|
/>
|
@ -228,7 +228,7 @@
|
|||||||
@info={{info}}
|
@info={{info}}
|
||||||
@model={{this.model}}
|
@model={{this.model}}
|
||||||
@replyToPost={{action "replyToPost"}}
|
@replyToPost={{action "replyToPost"}}
|
||||||
@showSummary={{action "showSummary"}}
|
@showTopReplies={{action "showTopReplies"}}
|
||||||
@jumpToPostPrompt={{action "jumpToPostPrompt"}}
|
@jumpToPostPrompt={{action "jumpToPostPrompt"}}
|
||||||
@enteredIndex={{this.enteredIndex}}
|
@enteredIndex={{this.enteredIndex}}
|
||||||
@prevEvent={{info.prevEvent}}
|
@prevEvent={{info.prevEvent}}
|
||||||
@ -342,7 +342,7 @@
|
|||||||
@unhidePost={{action "unhidePost"}}
|
@unhidePost={{action "unhidePost"}}
|
||||||
@replyToPost={{action "replyToPost"}}
|
@replyToPost={{action "replyToPost"}}
|
||||||
@toggleWiki={{action "toggleWiki"}}
|
@toggleWiki={{action "toggleWiki"}}
|
||||||
@showSummary={{action "showSummary"}}
|
@showTopReplies={{action "showTopReplies"}}
|
||||||
@cancelFilter={{action "cancelFilter"}}
|
@cancelFilter={{action "cancelFilter"}}
|
||||||
@removeAllowedUser={{action "removeAllowedUser"}}
|
@removeAllowedUser={{action "removeAllowedUser"}}
|
||||||
@removeAllowedGroup={{action "removeAllowedGroup"}}
|
@removeAllowedGroup={{action "removeAllowedGroup"}}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import I18n from "I18n";
|
import I18n from "I18n";
|
||||||
import RawHtml from "discourse/widgets/raw-html";
|
import RawHtml from "discourse/widgets/raw-html";
|
||||||
import { createWidget } from "discourse/widgets/widget";
|
import { createWidget } from "discourse/widgets/widget";
|
||||||
|
import { h } from "virtual-dom";
|
||||||
|
|
||||||
const MIN_POST_READ_TIME = 4;
|
const MIN_POST_READ_TIME = 4;
|
||||||
|
|
||||||
@ -31,32 +32,43 @@ createWidget("toggle-summary-description", {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let topicSummaryCallbacks = null;
|
|
||||||
export function addTopicSummaryCallback(callback) {
|
|
||||||
topicSummaryCallbacks = topicSummaryCallbacks || [];
|
|
||||||
topicSummaryCallbacks.push(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default createWidget("toggle-topic-summary", {
|
export default createWidget("toggle-topic-summary", {
|
||||||
tagName: "section.information.toggle-summary",
|
tagName: "section.information.toggle-summary",
|
||||||
html(attrs) {
|
html(attrs) {
|
||||||
let html = [
|
const html = [];
|
||||||
this.attach("toggle-summary-description", attrs),
|
const summarizationButtons = [];
|
||||||
this.attach("button", {
|
|
||||||
className: "btn btn-primary",
|
|
||||||
icon: attrs.topicSummaryEnabled ? null : "layer-group",
|
|
||||||
title: attrs.topicSummaryEnabled ? null : "summary.short_title",
|
|
||||||
label: attrs.topicSummaryEnabled ? "summary.disable" : "summary.enable",
|
|
||||||
action: attrs.topicSummaryEnabled ? "cancelFilter" : "showSummary",
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (topicSummaryCallbacks) {
|
if (attrs.hasTopRepliesSummary) {
|
||||||
topicSummaryCallbacks.forEach((callback) => {
|
html.push(this.attach("toggle-summary-description", attrs));
|
||||||
html = callback(html, attrs, this);
|
summarizationButtons.push(
|
||||||
});
|
this.attach("button", {
|
||||||
|
className: "btn btn-primary",
|
||||||
|
icon: attrs.topicSummaryEnabled ? null : "layer-group",
|
||||||
|
title: attrs.topicSummaryEnabled ? null : "summary.short_title",
|
||||||
|
label: attrs.topicSummaryEnabled
|
||||||
|
? "summary.disable"
|
||||||
|
: "summary.enable",
|
||||||
|
action: attrs.topicSummaryEnabled ? "cancelFilter" : "showTopReplies",
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (attrs.includeSummary) {
|
||||||
|
const title = I18n.t("summary.strategy.button_title");
|
||||||
|
|
||||||
|
summarizationButtons.push(
|
||||||
|
this.attach("button", {
|
||||||
|
className: "btn btn-primary topic-strategy-summarization",
|
||||||
|
icon: "magic",
|
||||||
|
translatedTitle: title,
|
||||||
|
translatedLabel: title,
|
||||||
|
action: "showSummary",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.push(h("div.summarization-buttons", summarizationButtons));
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -376,7 +376,7 @@ export default createWidget("topic-map", {
|
|||||||
buildKey: (attrs) => `topic-map-${attrs.id}`,
|
buildKey: (attrs) => `topic-map-${attrs.id}`,
|
||||||
|
|
||||||
defaultState(attrs) {
|
defaultState(attrs) {
|
||||||
return { collapsed: !attrs.hasTopicSummary };
|
return { collapsed: !attrs.hasTopRepliesSummary };
|
||||||
},
|
},
|
||||||
|
|
||||||
html(attrs, state) {
|
html(attrs, state) {
|
||||||
@ -386,7 +386,8 @@ export default createWidget("topic-map", {
|
|||||||
contents.push(this.attach("topic-map-expanded", attrs));
|
contents.push(this.attach("topic-map-expanded", attrs));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attrs.hasTopicSummary) {
|
if (attrs.hasTopRepliesSummary || this._includesSummary()) {
|
||||||
|
attrs.includeSummary = this._includesSummary();
|
||||||
contents.push(this.attach("toggle-topic-summary", attrs));
|
contents.push(this.attach("toggle-topic-summary", attrs));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -399,4 +400,19 @@ export default createWidget("topic-map", {
|
|||||||
toggleMap() {
|
toggleMap() {
|
||||||
this.state.collapsed = !this.state.collapsed;
|
this.state.collapsed = !this.state.collapsed;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_includesSummary() {
|
||||||
|
const customSummaryAllowedGroups =
|
||||||
|
this.siteSettings.custom_summarization_allowed_groups
|
||||||
|
.split("|")
|
||||||
|
.map(parseInt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
this.siteSettings.summarization_strategy &&
|
||||||
|
this.currentUser &&
|
||||||
|
this.currentUser.groups.some((g) =>
|
||||||
|
customSummaryAllowedGroups.includes(g.id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
@ -840,12 +840,12 @@ module("Integration | Component | Widget | post", function (hooks) {
|
|||||||
assert.ok(!exists(".toggle-summary"));
|
assert.ok(!exists(".toggle-summary"));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("topic map - has summary", async function (assert) {
|
test("topic map - has top replies summary", async function (assert) {
|
||||||
this.set("args", { showTopicMap: true, hasTopicSummary: true });
|
this.set("args", { showTopicMap: true, hasTopRepliesSummary: true });
|
||||||
this.set("showSummary", () => (this.summaryToggled = true));
|
this.set("showTopReplies", () => (this.summaryToggled = true));
|
||||||
|
|
||||||
await render(
|
await render(
|
||||||
hbs`<MountWidget @widget="post" @args={{this.args}} @showSummary={{this.showSummary}} />`
|
hbs`<MountWidget @widget="post" @args={{this.args}} @showTopReplies={{this.showTopReplies}} />`
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.strictEqual(count(".toggle-summary"), 1);
|
assert.strictEqual(count(".toggle-summary"), 1);
|
||||||
|
@ -833,6 +833,12 @@ aside.quote {
|
|||||||
margin-left: 0.25em;
|
margin-left: 0.25em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toggle-summary {
|
||||||
|
.summarization-buttons {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic-avatar,
|
.topic-avatar,
|
||||||
|
@ -325,6 +325,10 @@ pre.codeblock-buttons:hover {
|
|||||||
background: var(--primary-low);
|
background: var(--primary-low);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toggle-summary .summarization-buttons .topic-strategy-summarization {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#topic-footer-buttons {
|
#topic-footer-buttons {
|
||||||
|
@ -199,6 +199,16 @@ a.reply-to-tab {
|
|||||||
background: var(--blend-primary-secondary-5);
|
background: var(--blend-primary-secondary-5);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toggle-summary {
|
||||||
|
.summarization-buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.topic-strategy-summarization {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#topic-footer-buttons {
|
#topic-footer-buttons {
|
||||||
|
@ -30,6 +30,7 @@ class TopicsController < ApplicationController
|
|||||||
publish
|
publish
|
||||||
reset_bump_date
|
reset_bump_date
|
||||||
set_slow_mode
|
set_slow_mode
|
||||||
|
summary
|
||||||
]
|
]
|
||||||
|
|
||||||
before_action :consider_user_for_promotion, only: :show
|
before_action :consider_user_for_promotion, only: :show
|
||||||
@ -1167,6 +1168,30 @@ class TopicsController < ApplicationController
|
|||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def summary
|
||||||
|
topic = Topic.find(params[:topic_id])
|
||||||
|
guardian.ensure_can_see!(topic)
|
||||||
|
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, "summary", 6, 5.minutes).performed!
|
||||||
|
|
||||||
|
hijack do
|
||||||
|
summary_opts = {
|
||||||
|
filter: "summary",
|
||||||
|
exclude_deleted_users: true,
|
||||||
|
exclude_hidden: true,
|
||||||
|
show_deleted: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
content = TopicView.new(topic, current_user, summary_opts).posts.pluck(:raw).join("\n")
|
||||||
|
|
||||||
|
render json: { summary: strategy.summarize(content) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def topic_params
|
def topic_params
|
||||||
|
16
app/models/summarization_strategy.rb
Normal file
16
app/models/summarization_strategy.rb
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "enum_site_setting"
|
||||||
|
|
||||||
|
class SummarizationStrategy < EnumSiteSetting
|
||||||
|
def self.valid_value?(val)
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.values
|
||||||
|
@values ||=
|
||||||
|
Summarization::Base.available_strategies.map do |strategy|
|
||||||
|
{ name: strategy.display_name, value: strategy.model }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -2054,10 +2054,13 @@ en:
|
|||||||
other {# minutes}
|
other {# minutes}
|
||||||
}</b>.
|
}</b>.
|
||||||
|
|
||||||
enable: "Summarize This Topic"
|
enable: "Show top replies"
|
||||||
disable: "Show All Posts"
|
disable: "Show All Posts"
|
||||||
short_label: "Summarize"
|
short_label: "Summarize"
|
||||||
short_title: "Show a summary of this topic: the most interesting posts as determined by the community"
|
short_title: "Show a summary of this topic: the most interesting posts as determined by the community"
|
||||||
|
strategy:
|
||||||
|
button_title: "Summarize this topic"
|
||||||
|
title: "Topic summary"
|
||||||
|
|
||||||
deleted_filter:
|
deleted_filter:
|
||||||
enabled_description: "This topic contains deleted posts, which have been hidden."
|
enabled_description: "This topic contains deleted posts, which have been hidden."
|
||||||
|
@ -1600,6 +1600,8 @@ en:
|
|||||||
summary_percent_filter: "When a user clicks 'Summarize This Topic', show the top % of posts"
|
summary_percent_filter: "When a user clicks 'Summarize This Topic', show the top % of posts"
|
||||||
summary_max_results: "Maximum posts returned by 'Summarize This Topic'"
|
summary_max_results: "Maximum posts returned by 'Summarize This Topic'"
|
||||||
summary_timeline_button: "Show a 'Summarize' button in the timeline"
|
summary_timeline_button: "Show a 'Summarize' button in the timeline"
|
||||||
|
summarization_strategy: "Additional ways to summarize content registered by plugins"
|
||||||
|
custom_summarization_allowed_groups: "Groups allowed to summarize contents using the `summarization_strategy`."
|
||||||
|
|
||||||
enable_personal_messages: "DEPRECATED, use the 'personal message enabled groups' setting instead. Allow trust level 1 (configurable via min trust to send messages) users to create messages and reply to messages. Note that staff can always send messages no matter what."
|
enable_personal_messages: "DEPRECATED, use the 'personal message enabled groups' setting instead. Allow trust level 1 (configurable via min trust to send messages) users to create messages and reply to messages. Note that staff can always send messages no matter what."
|
||||||
personal_message_enabled_groups: "Allow users within these groups to create messages and reply to messages. Trust level groups include all trust levels above that number, for example choosing trust_level_1 also allows trust_level_2, 3, 4 users to send PMs. Note that staff can always send messages no matter what."
|
personal_message_enabled_groups: "Allow users within these groups to create messages and reply to messages. Trust level groups include all trust levels above that number, for example choosing trust_level_1 also allows trust_level_2, 3, 4 users to send PMs. Note that staff can always send messages no matter what."
|
||||||
|
@ -1319,6 +1319,11 @@ Discourse::Application.routes.draw do
|
|||||||
topic_id: /\d+/,
|
topic_id: /\d+/,
|
||||||
}
|
}
|
||||||
get "t/:topic_id/summary" => "topics#show", :constraints => { topic_id: /\d+/ }
|
get "t/:topic_id/summary" => "topics#show", :constraints => { topic_id: /\d+/ }
|
||||||
|
get "t/:topic_id/strategy-summary" => "topics#summary",
|
||||||
|
:constraints => {
|
||||||
|
topic_id: /\d+/,
|
||||||
|
},
|
||||||
|
:format => :json
|
||||||
put "t/:slug/:topic_id" => "topics#update", :constraints => { topic_id: /\d+/ }
|
put "t/:slug/:topic_id" => "topics#update", :constraints => { topic_id: /\d+/ }
|
||||||
put "t/:slug/:topic_id/star" => "topics#star", :constraints => { topic_id: /\d+/ }
|
put "t/:slug/:topic_id/star" => "topics#star", :constraints => { topic_id: /\d+/ }
|
||||||
put "t/:topic_id/star" => "topics#star", :constraints => { topic_id: /\d+/ }
|
put "t/:topic_id/star" => "topics#star", :constraints => { topic_id: /\d+/ }
|
||||||
|
@ -2351,6 +2351,17 @@ uncategorized:
|
|||||||
client: true
|
client: true
|
||||||
default: false
|
default: false
|
||||||
|
|
||||||
|
summarization_strategy:
|
||||||
|
client: true
|
||||||
|
default: ""
|
||||||
|
enum: "SummarizationStrategy"
|
||||||
|
validator: "SummarizationValidator"
|
||||||
|
custom_summarization_allowed_groups:
|
||||||
|
client: true
|
||||||
|
type: group_list
|
||||||
|
list_type: compact
|
||||||
|
default: "3|14" # 3: @staff, 14: @trust_level_4
|
||||||
|
|
||||||
automatic_topic_heat_values: true
|
automatic_topic_heat_values: true
|
||||||
|
|
||||||
# View heat thresholds
|
# View heat thresholds
|
||||||
|
@ -113,6 +113,8 @@ class DiscoursePluginRegistry
|
|||||||
|
|
||||||
define_filtered_register :list_suggested_for_providers
|
define_filtered_register :list_suggested_for_providers
|
||||||
|
|
||||||
|
define_filtered_register :summarization_strategies
|
||||||
|
|
||||||
def self.register_auth_provider(auth_provider)
|
def self.register_auth_provider(auth_provider)
|
||||||
self.auth_providers << auth_provider
|
self.auth_providers << auth_provider
|
||||||
end
|
end
|
||||||
|
@ -1264,6 +1264,17 @@ class Plugin::Instance
|
|||||||
DiscoursePluginRegistry.register_bookmarkable(RegisteredBookmarkable.new(klass), self)
|
DiscoursePluginRegistry.register_bookmarkable(RegisteredBookmarkable.new(klass), self)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Register an object that inherits from [Summarization::Base], which provides a way
|
||||||
|
# to summarize content. Staff can select which strategy to use
|
||||||
|
# through the `summarization_strategy` setting.
|
||||||
|
def register_summarization_strategy(strategy)
|
||||||
|
if !strategy.class.ancestors.include?(Summarization::Base)
|
||||||
|
raise ArgumentError.new("Not a valid summarization strategy")
|
||||||
|
end
|
||||||
|
DiscoursePluginRegistry.register_summarization_strategy(strategy, self)
|
||||||
|
end
|
||||||
|
|
||||||
protected
|
protected
|
||||||
|
|
||||||
def self.js_path
|
def self.js_path
|
||||||
|
49
lib/summarization/base.rb
Normal file
49
lib/summarization/base.rb
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Summarization
|
||||||
|
class Base
|
||||||
|
def self.available_strategies
|
||||||
|
DiscoursePluginRegistry.summarization_strategies
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.find_strategy(strategy_model)
|
||||||
|
available_strategies.detect { |s| s.model == strategy_model }
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.selected_strategy
|
||||||
|
return if SiteSetting.summarization_strategy.blank?
|
||||||
|
|
||||||
|
find_strategy(SiteSetting.summarization_strategy)
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(model)
|
||||||
|
@model = model
|
||||||
|
end
|
||||||
|
|
||||||
|
attr_reader :model
|
||||||
|
|
||||||
|
def can_request_summaries?(user)
|
||||||
|
user_group_ids = user.group_ids
|
||||||
|
|
||||||
|
SiteSetting.custom_summarization_allowed_groups_map.any? do |group_id|
|
||||||
|
user_group_ids.include?(group_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def correctly_configured?
|
||||||
|
raise NotImplemented
|
||||||
|
end
|
||||||
|
|
||||||
|
def display_name
|
||||||
|
raise NotImplemented
|
||||||
|
end
|
||||||
|
|
||||||
|
def configuration_hint
|
||||||
|
raise NotImplemented
|
||||||
|
end
|
||||||
|
|
||||||
|
def summarize(content)
|
||||||
|
raise NotImplemented
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
19
lib/validators/summarization_validator.rb
Normal file
19
lib/validators/summarization_validator.rb
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class SummarizationValidator
|
||||||
|
def initialize(opts = {})
|
||||||
|
@opts = opts
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_value?(val)
|
||||||
|
strategy = Summarization::Base.find_strategy(val)
|
||||||
|
|
||||||
|
return true unless strategy
|
||||||
|
|
||||||
|
strategy.correctly_configured?.tap { |is_valid| @strategy = strategy unless is_valid }
|
||||||
|
end
|
||||||
|
|
||||||
|
def error_message
|
||||||
|
@strategy.configuration_hint
|
||||||
|
end
|
||||||
|
end
|
@ -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) {
|
#addMentionedUser(userData) {
|
||||||
const user = User.create(userData);
|
const user = User.create(userData);
|
||||||
this.currentMessage.mentionedUsers.set(user.id, user);
|
this.currentMessage.mentionedUsers.set(user.id, user);
|
||||||
|
@ -21,6 +21,7 @@ export default {
|
|||||||
this.chatService = container.lookup("service:chat");
|
this.chatService = container.lookup("service:chat");
|
||||||
this.site = container.lookup("service:site");
|
this.site = container.lookup("service:site");
|
||||||
this.siteSettings = container.lookup("service:site-settings");
|
this.siteSettings = container.lookup("service:site-settings");
|
||||||
|
this.currentUser = container.lookup("service:current-user");
|
||||||
this.appEvents = container.lookup("service:app-events");
|
this.appEvents = container.lookup("service:app-events");
|
||||||
this.appEvents.on("discourse:focus-changed", this, "_handleFocusChanged");
|
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
|
// we want to decorate the chat quote dates regardless
|
||||||
// of whether the current user has chat enabled
|
// of whether the current user has chat enabled
|
||||||
api.decorateCookedElement(
|
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-header";
|
||||||
@import "chat-thread-list-header";
|
@import "chat-thread-list-header";
|
||||||
@import "chat-thread-unread-indicator";
|
@import "chat-thread-unread-indicator";
|
||||||
|
@import "channel-summary-modal";
|
||||||
|
@ -114,6 +114,14 @@ en:
|
|||||||
heading: "Chat"
|
heading: "Chat"
|
||||||
join: "Join"
|
join: "Join"
|
||||||
last_visit: "last visit"
|
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:
|
mention_warning:
|
||||||
dismiss: "dismiss"
|
dismiss: "dismiss"
|
||||||
cannot_see: "%{username} can't access this channel and was not notified."
|
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"
|
put "/channels/:channel_id/messages/:message_id/restore" => "channel_messages#restore"
|
||||||
delete "/channels/:channel_id/messages/:message_id" => "channel_messages#destroy"
|
delete "/channels/:channel_id/messages/:message_id" => "channel_messages#destroy"
|
||||||
|
|
||||||
|
get "/channels/:channel_id/summarize" => "summaries#get_summary"
|
||||||
end
|
end
|
||||||
|
|
||||||
# direct_messages_controller routes
|
# 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
|
22
spec/lib/summarization/base_spec.rb
Normal file
22
spec/lib/summarization/base_spec.rb
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
describe Summarization::Base do
|
||||||
|
fab!(:user) { Fabricate(:user) }
|
||||||
|
fab!(:group) { Fabricate(:group) }
|
||||||
|
|
||||||
|
before { group.add(user) }
|
||||||
|
|
||||||
|
describe "#can_request_summaries?" do
|
||||||
|
it "returns true if the user group is present in the custom_summarization_allowed_groups_map setting" do
|
||||||
|
SiteSetting.custom_summarization_allowed_groups = group.id
|
||||||
|
|
||||||
|
expect(described_class.new(nil).can_request_summaries?(user)).to eq(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false if the user group is not present in the custom_summarization_allowed_groups_map setting" do
|
||||||
|
SiteSetting.custom_summarization_allowed_groups = ""
|
||||||
|
|
||||||
|
expect(described_class.new(nil).can_request_summaries?(user)).to eq(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -5456,4 +5456,57 @@ RSpec.describe TopicsController do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "#summary" do
|
||||||
|
fab!(:topic) { Fabricate(:topic) }
|
||||||
|
let(:plugin) { Plugin::Instance.new }
|
||||||
|
|
||||||
|
before do
|
||||||
|
strategy = DummyCustomSummarization.new("dummy")
|
||||||
|
plugin.register_summarization_strategy(strategy)
|
||||||
|
SiteSetting.summarization_strategy = strategy.model
|
||||||
|
end
|
||||||
|
|
||||||
|
context "for anons" do
|
||||||
|
it "returns a 404" do
|
||||||
|
get "/t/#{topic.id}/strategy-summary.json"
|
||||||
|
|
||||||
|
expect(response.status).to eq(403)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the user is a member of an allowlisted group" do
|
||||||
|
fab!(:user) { Fabricate(:leader) }
|
||||||
|
|
||||||
|
before { sign_in(user) }
|
||||||
|
|
||||||
|
it "returns a 404 if there is no topic" do
|
||||||
|
invalid_topic_id = 999
|
||||||
|
|
||||||
|
get "/t/#{invalid_topic_id}/strategy-summary.json"
|
||||||
|
|
||||||
|
expect(response.status).to eq(404)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns a 403 if not allowed to see the topic" do
|
||||||
|
pm = Fabricate(:private_message_topic)
|
||||||
|
|
||||||
|
get "/t/#{pm.id}/strategy-summary.json"
|
||||||
|
|
||||||
|
expect(response.status).to eq(403)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the user is not a member of an allowlited group" do
|
||||||
|
fab!(:user) { Fabricate(:user) }
|
||||||
|
|
||||||
|
before { sign_in(user) }
|
||||||
|
|
||||||
|
it "return a 404" do
|
||||||
|
get "/t/#{topic.id}/strategy-summary.json"
|
||||||
|
|
||||||
|
expect(response.status).to eq(403)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
21
spec/support/dummy_custom_summarization.rb
Normal file
21
spec/support/dummy_custom_summarization.rb
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class DummyCustomSummarization < Summarization::Base
|
||||||
|
RESPONSE = "This is a summary of the content you gave me"
|
||||||
|
|
||||||
|
def display_name
|
||||||
|
"dummy"
|
||||||
|
end
|
||||||
|
|
||||||
|
def correctly_configured?
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def configuration_hint
|
||||||
|
"hint"
|
||||||
|
end
|
||||||
|
|
||||||
|
def summarize(_content)
|
||||||
|
RESPONSE
|
||||||
|
end
|
||||||
|
end
|
29
spec/system/topic_summarization_spec.rb
Normal file
29
spec/system/topic_summarization_spec.rb
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
RSpec.describe "Topic summarization", type: :system, js: true do
|
||||||
|
fab!(:user) { Fabricate(:admin) }
|
||||||
|
|
||||||
|
# has_summary to force topic map to be present.
|
||||||
|
fab!(:topic) { Fabricate(:topic, has_summary: true) }
|
||||||
|
fab!(:post_1) { Fabricate(:post, topic: topic) }
|
||||||
|
fab!(:post_2) { Fabricate(:post, topic: topic) }
|
||||||
|
|
||||||
|
let(:plugin) { Plugin::Instance.new }
|
||||||
|
|
||||||
|
before do
|
||||||
|
sign_in(user)
|
||||||
|
strategy = DummyCustomSummarization.new("dummy")
|
||||||
|
plugin.register_summarization_strategy(strategy)
|
||||||
|
SiteSetting.summarization_strategy = strategy.model
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns a summary using the selected timeframe" do
|
||||||
|
visit("/t/-/#{topic.id}")
|
||||||
|
|
||||||
|
find(".topic-strategy-summarization").click
|
||||||
|
|
||||||
|
expect(page.has_css?(".topic-summary-modal", wait: 5)).to eq(true)
|
||||||
|
|
||||||
|
expect(find(".summary-area").text).to eq(DummyCustomSummarization::RESPONSE)
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in New Issue
Block a user