mirror of
https://github.com/discourse/discourse.git
synced 2026-08-08 12:08:12 -05:00
FEATURE: Ability to regenerate short summaries (#34345)
## 🔍 Overview This update allows you to regenerate short summaries (gists) with the click of a button. This is helpful for admins to fix short summaries when they go wrong. This update adds a regenerate button in the topic admin controls as well as the bulk select dropdown for topic lists. ## 📸 Screenshots <img width="443" height="663" alt="Screenshot 2025-08-14 at 17 28 46" src="https://github.com/user-attachments/assets/25784d3e-38b4-4eb6-8fde-a921ca9243f6" /> <img width="564" height="579" alt="Screenshot 2025-08-14 at 17 28 36" src="https://github.com/user-attachments/assets/26ef3037-2d79-4a32-bca9-baa94430bf18" /> --------- Co-authored-by: Roman Rizzi <rizziromanalejandro@gmail.com>
This commit is contained in:
co-authored by
Roman Rizzi
parent
fde10cb699
commit
fcb098e85d
@@ -41,6 +41,41 @@ module DiscourseAi
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def regen_gist
|
||||
raise Discourse::InvalidAccess if !guardian.can_request_gists?
|
||||
|
||||
topics = []
|
||||
|
||||
if params[:topic_ids].present?
|
||||
topic_ids =
|
||||
params[:topic_ids].is_a?(String) ? params[:topic_ids].split(",") : params[:topic_ids]
|
||||
topics = Topic.where(id: topic_ids)
|
||||
elsif params[:topic_id].present?
|
||||
topics = [Topic.find(params[:topic_id])]
|
||||
else
|
||||
raise Discourse::InvalidParameters.new(:topic_id)
|
||||
end
|
||||
|
||||
if current_user && topics.size >= 1
|
||||
RateLimiter.new(current_user, "summary", 6, 5.minutes).performed!
|
||||
end
|
||||
|
||||
if topics.size > TopicQuery::DEFAULT_PER_PAGE_COUNT
|
||||
raise Discourse::InvalidParameters.new(:topic_ids)
|
||||
end
|
||||
|
||||
topics.each do |topic|
|
||||
guardian.ensure_can_see!(topic)
|
||||
|
||||
summarizer = DiscourseAi::Summarization.topic_gist(topic)
|
||||
summarizer.delete_cached_summaries! if summarizer.present?
|
||||
|
||||
Jobs.enqueue(:fast_track_topic_gist, topic_id: topic.id, force_regenerate: true)
|
||||
end
|
||||
|
||||
render json: success_json
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -14,7 +14,10 @@ module ::Jobs
|
||||
|
||||
summarizer = DiscourseAi::Summarization.topic_gist(topic)
|
||||
gist = summarizer.existing_summary
|
||||
return if gist.present? && (!gist.outdated || gist.created_at >= 5.minutes.ago)
|
||||
|
||||
unless args[:force_regenerate]
|
||||
return if gist.present? && (!gist.outdated || gist.created_at >= 5.minutes.ago)
|
||||
end
|
||||
|
||||
summarizer.summarize(Discourse.system_user)
|
||||
end
|
||||
|
||||
@@ -1,9 +1,90 @@
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { apiInitializer } from "discourse/lib/api";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import AiTopicGist from "../components/ai-topic-gist";
|
||||
|
||||
export default apiInitializer((api) => {
|
||||
const site = api.container.lookup("service:site");
|
||||
const settings = api.container.lookup("service:site-settings");
|
||||
const MAX_ALLOWED_GISTS_REGENERATE = 30;
|
||||
|
||||
/**
|
||||
* Shared function to regenerate gists for one or more topics
|
||||
* @param {Array} topicIds - Array of topic ids
|
||||
* @param {Object} toasts - Toasts service for showing notifications
|
||||
* @param {Function} [onSuccess] - Optional callback on success
|
||||
*/
|
||||
async function regenerateGists(topicIds, toasts, onSuccess = null) {
|
||||
try {
|
||||
await ajax("/discourse-ai/summarization/regen_gist", {
|
||||
type: "PUT",
|
||||
data: { topic_ids: topicIds },
|
||||
});
|
||||
|
||||
// For single topic, refresh the gist data
|
||||
if (topicIds.length === 1) {
|
||||
await ajax(`/discourse-ai/summarization/t/${topicIds[0]}`, {
|
||||
type: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
toasts.success({
|
||||
duration: "short",
|
||||
data: {
|
||||
message: i18n("discourse_ai.summarization.topic.regenerate_success", {
|
||||
count: topicIds.length,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch {
|
||||
toasts.error({
|
||||
duration: "short",
|
||||
data: {
|
||||
message: i18n("discourse_ai.summarization.topic.regenerate_error", {
|
||||
count: topicIds.length,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
api.addBulkActionButton({
|
||||
label: "discourse_ai.summarization.topic.regenerate_bulk",
|
||||
icon: "arrows-rotate",
|
||||
class: "btn-default",
|
||||
visible: ({ topics, siteSettings, currentUser }) => {
|
||||
if (topics?.length > MAX_ALLOWED_GISTS_REGENERATE) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
siteSettings.discourse_ai_enabled &&
|
||||
siteSettings.ai_summarization_enabled &&
|
||||
siteSettings.ai_summary_gists_enabled &&
|
||||
currentUser.staff
|
||||
);
|
||||
},
|
||||
async action(opts) {
|
||||
const topics = opts.model.bulkSelectHelper.selected;
|
||||
const topicIds = topics.map((topic) => topic.id);
|
||||
const toasts = opts.toasts;
|
||||
|
||||
await regenerateGists(topicIds, toasts, () => {
|
||||
// We don't call `opts.performAndRefresh` here because we want to
|
||||
// avoid `this.perform()` from being called since we don't need
|
||||
// a put request to `/topics/bulk`
|
||||
opts.model.refreshClosure?.().then(() => {
|
||||
opts.args.closeModal();
|
||||
opts.model.bulkSelectHelper.toggleBulkSelect();
|
||||
opts.showToast();
|
||||
});
|
||||
});
|
||||
},
|
||||
actionType: "performAndRefresh",
|
||||
});
|
||||
|
||||
if (settings.discourse_ai_enabled && settings.ai_summarization_enabled) {
|
||||
const gistTemplate = <template>
|
||||
@@ -15,5 +96,25 @@ export default apiInitializer((api) => {
|
||||
: "topic-list-topic-cell-link-bottom-line__before";
|
||||
|
||||
api.renderInOutlet(outlet, gistTemplate);
|
||||
|
||||
api.addTopicAdminMenuButton((topic) => {
|
||||
if (!settings.ai_summary_gists_enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
action: async () => {
|
||||
const topicId = topic.id;
|
||||
const toasts = api.container.lookup("service:toasts");
|
||||
|
||||
await regenerateGists([topicId], toasts, () => {
|
||||
window.location.reload();
|
||||
});
|
||||
},
|
||||
icon: "arrows-rotate",
|
||||
className: "regenerate-gist-button",
|
||||
label: "discourse_ai.summarization.topic.regenerate",
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -274,7 +274,7 @@ en:
|
||||
automation_reports:
|
||||
name: "Automated reports"
|
||||
description: "Automatically creates concise overviews of activity, highlighting trends, key topics, and engagement"
|
||||
|
||||
|
||||
automation_triage:
|
||||
name: "Posts triage"
|
||||
description: "Automates moderation and management of posts by using configurable automation rules"
|
||||
@@ -929,6 +929,14 @@ en:
|
||||
topic:
|
||||
title: "Topic summary"
|
||||
close: "Close summary panel"
|
||||
regenerate: "Regenerate short summary"
|
||||
regenerate_bulk: "Regenerate short summaries"
|
||||
regenerate_success:
|
||||
one: "Short summary regenerated successfully"
|
||||
other: "%{count} short summaries regenerated successfully"
|
||||
regenerate_error:
|
||||
one: "Failed to regenerate short summary"
|
||||
other: "Failed to regenerate %{count} short summaries"
|
||||
topic_list_layout:
|
||||
button:
|
||||
compact: "Compact"
|
||||
|
||||
@@ -55,6 +55,7 @@ DiscourseAi::Engine.routes.draw do
|
||||
|
||||
scope module: :summarization, path: "/summarization", defaults: { format: :json } do
|
||||
get "/t/:topic_id" => "summary#show", :constraints => { topic_id: /\d+/ }
|
||||
put "/regen_gist" => "summary#regen_gist"
|
||||
get "/channels/:channel_id" => "chat_summary#show"
|
||||
end
|
||||
|
||||
|
||||
@@ -339,6 +339,7 @@ discourse_ai:
|
||||
hidden: true
|
||||
ai_summary_gists_enabled:
|
||||
default: false
|
||||
client: true
|
||||
area: "ai-features/summarization"
|
||||
ai_summary_gists_persona:
|
||||
default: "-12"
|
||||
|
||||
@@ -35,6 +35,14 @@ module DiscourseAi
|
||||
persona_groups.any? { |group_id| user.group_ids.include?(group_id) }
|
||||
end
|
||||
|
||||
def can_request_gists?
|
||||
return false if !SiteSetting.ai_summarization_enabled
|
||||
return false if !SiteSetting.ai_summary_gists_enabled
|
||||
return false if !AiPersona.exists?(id: SiteSetting.ai_summary_gists_persona)
|
||||
|
||||
is_staff?
|
||||
end
|
||||
|
||||
def can_request_summary?
|
||||
return false if anonymous?
|
||||
|
||||
|
||||
@@ -37,6 +37,16 @@ RSpec.describe Jobs::FastTrackTopicGist do
|
||||
expect(AiSummary.gist.where(target: topic_1).count).to eq(1)
|
||||
expect(gist.summarized_text).not_to eq(updated_gist)
|
||||
end
|
||||
|
||||
it "regenerates when force_regenerate is true" do
|
||||
DiscourseAi::Completions::Llm.with_prepared_responses([updated_gist]) do
|
||||
job.execute(topic_id: topic_1.id, force_regenerate: true)
|
||||
end
|
||||
|
||||
gist = AiSummary.gist.find_by(target: topic_1)
|
||||
expect(AiSummary.gist.where(target: topic_1).count).to eq(1)
|
||||
expect(gist.summarized_text).to eq(updated_gist)
|
||||
end
|
||||
end
|
||||
|
||||
context "when it's outdated" do
|
||||
@@ -65,6 +75,19 @@ RSpec.describe Jobs::FastTrackTopicGist do
|
||||
expect(gist.summarized_text).not_to eq(updated_gist)
|
||||
expect(gist.original_content_sha).to eq(AiSummary.build_sha("12"))
|
||||
end
|
||||
|
||||
it "regenerates when force_regenerate is true even if created recently" do
|
||||
ai_gist.update!(created_at: 2.minutes.ago)
|
||||
|
||||
DiscourseAi::Completions::Llm.with_prepared_responses([updated_gist]) do
|
||||
job.execute(topic_id: topic_1.id, force_regenerate: true)
|
||||
end
|
||||
|
||||
gist = AiSummary.gist.find_by(target: topic_1)
|
||||
expect(AiSummary.gist.where(target: topic_1).count).to eq(1)
|
||||
expect(gist.summarized_text).to eq(updated_gist)
|
||||
expect(gist.original_content_sha).to eq(AiSummary.build_sha("123"))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -132,4 +132,75 @@ RSpec.describe DiscourseAi::Summarization::SummaryController do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#regen_gist" do
|
||||
fab!(:admin)
|
||||
fab!(:group)
|
||||
fab!(:topic)
|
||||
fab!(:post_1) { Fabricate(:post, topic: topic, post_number: 1) }
|
||||
fab!(:post_2) { Fabricate(:post, topic: topic, post_number: 2) }
|
||||
|
||||
fab!(:topic_1) { Fabricate(:topic) }
|
||||
fab!(:post_3) { Fabricate(:post, topic: topic_1, post_number: 1) }
|
||||
fab!(:post_4) { Fabricate(:post, topic: topic_1, post_number: 2) }
|
||||
|
||||
before do
|
||||
enable_current_plugin
|
||||
assign_fake_provider_to(:ai_default_llm_model)
|
||||
SiteSetting.ai_summarization_enabled = true
|
||||
SiteSetting.ai_summary_gists_enabled = true
|
||||
|
||||
group.add(admin)
|
||||
assign_persona_to(:ai_summary_gists_persona, [group.id])
|
||||
Jobs.run_immediately!
|
||||
end
|
||||
|
||||
context "when a single topic id is provided" do
|
||||
before { sign_in(admin) }
|
||||
|
||||
it "regenerates the gist" do
|
||||
put "/discourse-ai/summarization/regen_gist", params: { topic_id: topic.id }
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
expect(AiSummary.gist.where(target: topic).count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context "when multiple topic ids are provided" do
|
||||
before { sign_in(admin) }
|
||||
|
||||
it "regenerates the gists" do
|
||||
put "/discourse-ai/summarization/regen_gist", params: { topic_ids: [topic.id, topic_1.id] }
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
expect(AiSummary.gist.where(target: topic).count).to eq(1)
|
||||
expect(AiSummary.gist.where(target: topic_1).count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context "when more than 30 topics are provided" do
|
||||
before { sign_in(admin) }
|
||||
|
||||
it "raises an error" do
|
||||
topics = 31.times.map { Fabricate(:topic) }
|
||||
topic_ids = topics.map(&:id)
|
||||
|
||||
put "/discourse-ai/summarization/regen_gist", params: { topic_ids: topic_ids }
|
||||
|
||||
expect(response.status).to eq(400)
|
||||
end
|
||||
end
|
||||
|
||||
context "when user is not allowed to regenerate gists" do
|
||||
fab!(:user)
|
||||
|
||||
before { sign_in(user) }
|
||||
|
||||
it "returns a 403" do
|
||||
put "/discourse-ai/summarization/regen_gist", params: { topic_id: topic.id }
|
||||
|
||||
expect(response.status).to eq(403)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user