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:
parent
9783e3b025
commit
38e007a3a5
|
@ -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
|
||||||
|
|
|
@ -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>
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
|
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
<AiSummary
|
||||||
|
@targetId={{this.targetId}}
|
||||||
|
@targetType={{this.targetType}}
|
||||||
|
@allowTimeframe={{this.allowTimeframe}}
|
||||||
|
@closeModal={{route-action "closeModal"}}
|
||||||
|
/>
|
|
@ -1,4 +0,0 @@
|
||||||
<ChatChannelSummary
|
|
||||||
@chatChannel={{this.chatChannel}}
|
|
||||||
@closeModal={{route-action "closeModal"}}
|
|
||||||
/>
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue