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:
Keegan George
2025-08-18 13:48:41 -07:00
committed by GitHub
co-authored by Roman Rizzi
parent fde10cb699
commit fcb098e85d
9 changed files with 253 additions and 2 deletions
@@ -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"
+1
View File
@@ -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
+1
View File
@@ -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