FEATURE: Regenerate outdated summaries. (#22718)

Users unable to generate new summaries won't be able to regenerate them. They'll only see the warning saying it's outdated.
This commit is contained in:
Roman Rizzi 2023-07-20 15:25:46 -03:00 committed by GitHub
parent 3349ce2c79
commit 238d71bcad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 208 additions and 90 deletions

View File

@ -50,54 +50,86 @@ export default createWidget("summary-box", {
html(attrs) { html(attrs) {
const html = []; const html = [];
if (attrs.summary) { const summary = attrs.summary;
if (summary && !attrs.skipAgeCheck) {
html.push( html.push(
new RawHtml({ new RawHtml({
html: `<div class="generated-summary">${attrs.summary}</div>`, html: `<div class="generated-summary">${summary.summarized_text}</div>`,
}) })
); );
html.push(
h("div.summarized-on", {}, [ const summarizationInfo = [
new RenderGlimmer( h("p", {}, [
this, I18n.t("summary.summarized_on", { date: summary.summarized_on }),
"div", this.buildTooltip(attrs),
hbs`{{@data.summarizedOn}} ]),
{{d-icon "info-circle"}} ];
<DTooltip @placement="top-end">
{{i18n "summary.model_used" model=@data.attrs.summarizedBy}} if (summary.outdated) {
</DTooltip>`, summarizationInfo.push(this.outdatedSummaryWarning(attrs));
{ }
attrs,
summarizedOn: I18n.t("summary.summarized_on", { html.push(h("div.summarized-on", {}, summarizationInfo));
date: attrs.summarizedOn,
}),
}
),
])
);
} else { } else {
html.push(this.attach("summary-skeleton")); html.push(this.attach("summary-skeleton"));
this.fetchSummary(attrs.topicId); this.fetchSummary(attrs.topicId, attrs.skipAgeCheck);
} }
return html; return html;
}, },
showFullSummarizedOn() { buildTooltip(attrs) {
this.state.expandSummarizedOn = true; return new RenderGlimmer(
this.scheduleRerender(); this,
"span",
hbs`{{d-icon "info-circle"}}<DTooltip @placement="top-end">
{{i18n "summary.model_used" model=@data.summarizedBy}}
</DTooltip>`,
{
summarizedBy: attrs.summary.summarized_by,
}
);
}, },
fetchSummary(topicId) { outdatedSummaryWarning(attrs) {
ajax(`/t/${topicId}/strategy-summary`) let outdatedText = I18n.t("summary.outdated");
if (
!attrs.hasTopRepliesSummary &&
attrs.summary.new_posts_since_summary > 0
) {
outdatedText += " ";
outdatedText += I18n.t("summary.outdated_posts", {
count: attrs.summary.new_posts_since_summary,
});
}
return h("p.outdated-summary", {}, [
outdatedText,
iconNode("exclamation-triangle", { class: "info-icon" }),
]);
},
fetchSummary(topicId, skipAgeCheck) {
let fetchURL = `/t/${topicId}/strategy-summary`;
if (skipAgeCheck) {
fetchURL += "?skip_age_check=true";
}
ajax(fetchURL)
.then((data) => { .then((data) => {
cookAsync(data.summary).then((cooked) => { cookAsync(data.summary).then((cooked) => {
// We store the summary in the parent so we can re-render it without doing a new request. // We store the summary in the parent so we can re-render it without doing a new request.
this.sendWidgetEvent("summaryUpdated", { data.summarized_text = cooked.string;
summary: cooked.string, data.summarized_on = shortDateNoYear(data.summarized_on);
summarizedOn: shortDateNoYear(data.summarized_on),
summarizedBy: data.summarized_by, if (skipAgeCheck) {
}); data.regenerated = true;
}
this.sendWidgetEvent("summaryUpdated", data);
}); });
}) })
.catch(popupAjaxError); .catch(popupAjaxError);

View File

@ -51,22 +51,17 @@ export default createWidget("toggle-topic-summary", {
const summarizationButtons = []; const summarizationButtons = [];
if (attrs.summarizable) { if (attrs.summarizable) {
const expandTitle = I18n.t("summary.strategy.button_title"); const canRegenerate =
const collapseTitle = I18n.t("summary.strategy.hide_button_title"); !state.regenerate &&
const canCollapse = !this.loadingSummary() && this.summaryBoxVisble(); state.summary.outdated &&
state.summary.can_regenerate;
const canCollapse =
!canRegenerate && !this.loadingSummary() && this.summaryBoxVisble();
const summarizeButton = canCollapse
? this.hideSummaryButton()
: this.generateSummaryButton(canRegenerate);
summarizationButtons.push( summarizationButtons.push(summarizeButton);
this.attach("button", {
className: "btn btn-primary topic-strategy-summarization",
icon: canCollapse ? "chevron-up" : "magic",
translatedTitle: canCollapse ? collapseTitle : expandTitle,
translatedLabel: canCollapse ? collapseTitle : expandTitle,
action: state.expandSummaryBox
? "toggleSummaryBox"
: "expandSummaryBox",
disabled: this.loadingSummary(),
})
);
} }
if (attrs.hasTopRepliesSummary) { if (attrs.hasTopRepliesSummary) {
@ -89,23 +84,55 @@ export default createWidget("toggle-topic-summary", {
} }
if (this.summaryBoxVisble()) { if (this.summaryBoxVisble()) {
attrs.summary = this.state.summary; attrs.summary = state.summary;
attrs.summarizedOn = this.state.summarizedOn; attrs.skipAgeCheck = state.regenerate;
attrs.summarizedBy = this.state.summarizedBy;
html.push(this.attach("summary-box", attrs)); html.push(this.attach("summary-box", attrs));
} }
return html; return html;
}, },
loadingSummary() { generateSummaryButton(canRegenerate) {
return this.summaryBoxVisble() && !this.state.summary; const title = canRegenerate
? "summary.buttons.regenerate"
: "summary.buttons.generate";
const icon = canRegenerate ? "sync" : "magic";
return this.attach("button", {
className: "btn btn-primary topic-strategy-summarization",
icon,
title: I18n.t(title),
translatedTitle: I18n.t(title),
translatedLabel: I18n.t(title),
action: canRegenerate ? "regenerateSummary" : "expandSummaryBox",
disabled: this.loadingSummary(),
});
}, },
summaryUpdatedEvent(update) { hideSummaryButton() {
this.state.summary = update.summary; return this.attach("button", {
this.state.summarizedOn = update.summarizedOn; className: "btn btn-primary topic-strategy-summarization",
this.state.summarizedBy = update.summarizedBy; icon: "chevron-up",
title: "summary.buttons.hide",
label: "summary.buttons.hide",
action: "toggleSummaryBox",
disabled: this.loadingSummary(),
});
},
loadingSummary() {
return (
this.summaryBoxVisble() && (!this.state.summary || this.state.regenerate)
);
},
summaryUpdatedEvent(summary) {
this.state.summary = summary;
if (summary.regenerated) {
this.state.regenerate = false;
}
}, },
summaryBoxVisble() { summaryBoxVisble() {
@ -117,6 +144,10 @@ export default createWidget("toggle-topic-summary", {
this.state.summaryBoxHidden = false; this.state.summaryBoxHidden = false;
}, },
regenerateSummary() {
this.state.regenerate = true;
},
toggleSummaryBox() { toggleSummaryBox() {
this.state.summaryBoxHidden = !this.state.summaryBoxHidden; this.state.summaryBoxHidden = !this.state.summaryBoxHidden;
}, },

View File

@ -30,9 +30,13 @@
.summarized-on { .summarized-on {
text-align: right; text-align: right;
.model-used { .info-icon {
margin-left: 3px; margin-left: 3px;
} }
} }
.outdated-summary {
color: var(--primary-medium);
}
} }
} }

View File

@ -1179,13 +1179,19 @@ class TopicsController < ApplicationController
RateLimiter.new(current_user, "summary", 6, 5.minutes).performed! if current_user RateLimiter.new(current_user, "summary", 6, 5.minutes).performed! if current_user
opts = params.permit(:skip_age_check)
hijack do hijack do
summary = TopicSummarization.new(strategy).summarize(topic, current_user) summary = TopicSummarization.new(strategy).summarize(topic, current_user, opts)
render json: { render json: {
summary: summary.summarized_text, summary: summary.summarized_text,
summarized_on: summary.updated_at, summarized_on: summary.updated_at,
summarized_by: summary.algorithm, summarized_by: summary.algorithm,
outdated: summary.outdated,
can_regenerate: Summarization::Base.can_request_summary_for?(current_user),
new_posts_since_summary:
topic.highest_post_number.to_i - summary.content_range&.max.to_i,
} }
end end
end end

View File

@ -2,6 +2,14 @@
class SummarySection < ActiveRecord::Base class SummarySection < ActiveRecord::Base
belongs_to :target, polymorphic: true belongs_to :target, polymorphic: true
def mark_as_outdated
@outdated = true
end
def outdated
@outdated || false
end
end end
# == Schema Information # == Schema Information

View File

@ -5,14 +5,25 @@ class TopicSummarization
@strategy = strategy @strategy = strategy
end end
def summarize(topic, user) def summarize(topic, user, opts = {})
existing_summary = SummarySection.find_by(target: topic, meta_section_id: nil) existing_summary = SummarySection.find_by(target: topic, meta_section_id: nil)
# For users without permissions to generate a summary, we return what we have cached.
# Existing summary shouldn't be nil in this scenario because the controller checks its existence. # Existing summary shouldn't be nil in this scenario because the controller checks its existence.
return existing_summary if !user || !Summarization::Base.can_request_summary_for?(user) return if !user && !existing_summary
return existing_summary if existing_summary && fresh?(existing_summary, topic) targets_data = summary_targets(topic).pluck(:post_number, :raw, :username)
current_topic_sha = build_sha(targets_data.map(&:first))
can_summarize = Summarization::Base.can_request_summary_for?(user)
if use_cached?(existing_summary, can_summarize, current_topic_sha, !!opts[:skip_age_check])
# It's important that we signal a cached summary is outdated
if can_summarize && new_targets?(existing_summary, current_topic_sha)
existing_summary.mark_as_outdated
end
return existing_summary
end
delete_cached_summaries_of(topic) if existing_summary delete_cached_summaries_of(topic) if existing_summary
@ -22,8 +33,6 @@ class TopicSummarization
contents: [], contents: [],
} }
targets_data = summary_targets(topic).pluck(:post_number, :raw, :username)
targets_data.map do |(pn, raw, username)| targets_data.map do |(pn, raw, username)|
content[:contents] << { poster: username, id: pn, text: raw } content[:contents] << { poster: username, id: pn, text: raw }
end end
@ -34,7 +43,7 @@ class TopicSummarization
end end
def summary_targets(topic) def summary_targets(topic)
@targets ||= topic.has_summary? ? best_replies(topic) : pick_selection(topic) topic.has_summary? ? best_replies(topic) : pick_selection(topic)
end end
private private
@ -73,11 +82,17 @@ class TopicSummarization
SummarySection.where(target: topic).destroy_all SummarySection.where(target: topic).destroy_all
end end
def fresh?(summary, topic) # For users without permissions to generate a summary or fresh summaries, we return what we have cached.
return true if summary.created_at > 12.hours.ago def use_cached?(existing_summary, can_summarize, current_sha, skip_age_check)
latest_post_to_summarize = summary_targets(topic).last.post_number existing_summary &&
!(
can_summarize && new_targets?(existing_summary, current_sha) &&
(skip_age_check || existing_summary.created_at < 1.hour.ago)
)
end
latest_post_to_summarize <= summary.content_range.to_a.last def new_targets?(summary, current_sha)
summary.original_content_sha != current_sha
end end
def cache_summary(result, post_numbers, topic) def cache_summary(result, post_numbers, topic)
@ -87,7 +102,7 @@ class TopicSummarization
algorithm: strategy.model, algorithm: strategy.model,
content_range: (post_numbers.first..post_numbers.last), content_range: (post_numbers.first..post_numbers.last),
summarized_text: result[:summary], summarized_text: result[:summary],
original_content_sha: Digest::SHA256.hexdigest(post_numbers.join), original_content_sha: build_sha(post_numbers),
) )
result[:chunks].each do |chunk| result[:chunks].each do |chunk|
@ -96,11 +111,15 @@ class TopicSummarization
algorithm: strategy.model, algorithm: strategy.model,
content_range: chunk[:ids].min..chunk[:ids].max, content_range: chunk[:ids].min..chunk[:ids].max,
summarized_text: chunk[:summary], summarized_text: chunk[:summary],
original_content_sha: Digest::SHA256.hexdigest(chunk[:ids].join), original_content_sha: build_sha(chunk[:ids]),
meta_section_id: main_summary.id, meta_section_id: main_summary.id,
) )
end end
main_summary main_summary
end end
def build_sha(ids)
Digest::SHA256.hexdigest(ids.join)
end
end end

View File

@ -2043,10 +2043,18 @@ en:
in_progress: "Summarizing topic using AI..." in_progress: "Summarizing topic using AI..."
summarized_on: "Summarized with AI on %{date}" summarized_on: "Summarized with AI on %{date}"
model_used: "AI used: %{model}" model_used: "AI used: %{model}"
outdated: "Summary is outdated"
outdated_posts:
one: "(%{count} post missing)"
other: "(%{count} posts missing)"
enabled_description: "You're viewing this topic top replies: the most interesting posts as determined by the community." enabled_description: "You're viewing this topic top replies: the most interesting posts as determined by the community."
description: description:
one: "There is <b>%{count}</b> reply." one: "There is <b>%{count}</b> reply."
other: "There are <b>%{count}</b> replies." other: "There are <b>%{count}</b> replies."
buttons:
hide: "Hide summary"
generate: "Summarize with AI"
regenerate: "Regenerate summary"
# This string uses the ICU Message Format. See https://meta.discourse.org/t/7035 for translation guidelines. # This string uses the ICU Message Format. See https://meta.discourse.org/t/7035 for translation guidelines.
description_time_MF: | description_time_MF: |
@ -2062,9 +2070,6 @@ en:
disable: "Show All Posts" disable: "Show All Posts"
short_label: "Top replies" short_label: "Top replies"
short_title: "Show this topic top replies: the most interesting posts as determined by the community" short_title: "Show this topic top replies: the most interesting posts as determined by the community"
strategy:
hide_button_title: "Hide summary"
button_title: "Summarize with AI"
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."

View File

@ -147,29 +147,42 @@ describe TopicSummarization do
cached_summary.update!(summarized_text: cached_text, created_at: 24.hours.ago) cached_summary.update!(summarized_text: cached_text, created_at: 24.hours.ago)
end end
context "when the summary targets changed" do context "when the user can requests new summaries" do
before { cached_summary.update!(content_range: (1..1)) } context "when there are no new posts" do
it "returns the cached summary" do
section = summarization.summarize(topic, user)
it "deletes existing summaries and create a new one" do expect(section.summarized_text).to eq(cached_text)
section = summarization.summarize(topic, user) end
expect(section.summarized_text).to eq(summarized_text)
end end
it "does nothing if the last summary is less than 12 hours old" do context "when there are new posts" do
cached_summary.update!(created_at: 6.hours.ago) before { cached_summary.update!(original_content_sha: "outdated_sha") }
section = summarization.summarize(topic, user) it "returns a new summary" do
section = summarization.summarize(topic, user)
expect(section.summarized_text).to eq(cached_text) expect(section.summarized_text).to eq(summarized_text)
end end
end
context "when the summary targets are still the same" do context "when the cached summary is less than one hour old" do
it "doesn't create a new summary" do before { cached_summary.update!(created_at: 30.minutes.ago) }
section = summarization.summarize(topic, user)
expect(section.summarized_text).to eq(cached_text) it "returns the cached summary" do
cached_summary.update!(created_at: 30.minutes.ago)
section = summarization.summarize(topic, user)
expect(section.summarized_text).to eq(cached_text)
expect(section.outdated).to eq(true)
end
it "returns a new summary if the skip_age_check flag is passed" do
section = summarization.summarize(topic, user, skip_age_check: true)
expect(section.summarized_text).to eq(summarized_text)
end
end
end end
end end
end end