diff --git a/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js b/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js index 856b26359b8..c5d16602034 100644 --- a/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js +++ b/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js @@ -8,7 +8,6 @@ import offsetCalculator from "discourse/lib/offset-calculator"; import { inject as service } from "@ember/service"; import { bind } from "discourse-common/utils/decorators"; import domUtils from "discourse-common/utils/dom-utils"; -import showModal from "discourse/lib/show-modal"; const DEBOUNCE_DELAY = 50; @@ -269,12 +268,6 @@ export default MountWidget.extend({ this.screenTrack.setOnscreen(onscreenPostNumbers, readPostNumbers); }, - showSummary() { - showModal("topic-summary").setProperties({ - topicId: this.posts.objectAt(0).topic_id, - }); - }, - _scrollTriggered() { scheduleOnce("afterRender", this, this.scrolled); }, diff --git a/app/assets/javascripts/discourse/app/components/topic-summary.hbs b/app/assets/javascripts/discourse/app/components/topic-summary.hbs deleted file mode 100644 index e5b491c0604..00000000000 --- a/app/assets/javascripts/discourse/app/components/topic-summary.hbs +++ /dev/null @@ -1,14 +0,0 @@ - -
- - - {{#unless this.loading}} -

{{this.summary}}

- {{/unless}} -
- -
- - \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/topic-summary.js b/app/assets/javascripts/discourse/app/components/topic-summary.js deleted file mode 100644 index c86b03777ec..00000000000 --- a/app/assets/javascripts/discourse/app/components/topic-summary.js +++ /dev/null @@ -1,28 +0,0 @@ -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"; -import { cookAsync } from "discourse/lib/text"; - -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) => { - cookAsync(data.summary).then((cooked) => { - this.summary = cooked; - }); - }) - .catch(popupAjaxError) - .finally(() => (this.loading = false)); - }); - } -} diff --git a/app/assets/javascripts/discourse/app/lib/formatter.js b/app/assets/javascripts/discourse/app/lib/formatter.js index 6493ba18e8e..33004ab7666 100644 --- a/app/assets/javascripts/discourse/app/lib/formatter.js +++ b/app/assets/javascripts/discourse/app/lib/formatter.js @@ -7,7 +7,7 @@ export function shortDate(date) { return moment(date).format(I18n.t("dates.medium.date_year")); } -function shortDateNoYear(date) { +export function shortDateNoYear(date) { return moment(date).format(I18n.t("dates.tiny.date_month")); } diff --git a/app/assets/javascripts/discourse/app/lib/transform-post.js b/app/assets/javascripts/discourse/app/lib/transform-post.js index f8f586ae67e..740c7c7f58e 100644 --- a/app/assets/javascripts/discourse/app/lib/transform-post.js +++ b/app/assets/javascripts/discourse/app/lib/transform-post.js @@ -219,6 +219,7 @@ export default function transformPost( postAtts.topicSummaryEnabled = postStream.summary; postAtts.topicWordCount = topic.word_count; postAtts.hasTopRepliesSummary = topic.has_summary; + postAtts.summarizable = topic.summarizable; } if (postAtts.isDeleted) { diff --git a/app/assets/javascripts/discourse/app/templates/modal/topic-summary.hbs b/app/assets/javascripts/discourse/app/templates/modal/topic-summary.hbs deleted file mode 100644 index b745e904c03..00000000000 --- a/app/assets/javascripts/discourse/app/templates/modal/topic-summary.hbs +++ /dev/null @@ -1,4 +0,0 @@ - \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/widgets/summary-box.js b/app/assets/javascripts/discourse/app/widgets/summary-box.js new file mode 100644 index 00000000000..a3f00e4d8b5 --- /dev/null +++ b/app/assets/javascripts/discourse/app/widgets/summary-box.js @@ -0,0 +1,65 @@ +import { createWidget } from "discourse/widgets/widget"; +import hbs from "discourse/widgets/hbs-compiler"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { cookAsync } from "discourse/lib/text"; +import RawHtml from "discourse/widgets/raw-html"; +import I18n from "I18n"; +import { shortDateNoYear } from "discourse/lib/formatter"; +import { h } from "virtual-dom"; + +createWidget("summary-skeleton", { + tagName: "section.placeholder-summary", + template: hbs` +
+
+
+
{{transformed.in_progress_label}}
+ `, + + transform() { + return { in_progress_label: I18n.t("summary.in_progress") }; + }, +}); + +export default createWidget("summary-box", { + tagName: "article.summary-box", + buildKey: (attrs) => `summary-box-${attrs.topicId}`, + + defaultState() { + return { summary: "" }; + }, + + html(attrs, state) { + const html = []; + + if (state.summary) { + html.push(new RawHtml({ html: state.summary })); + html.push( + h( + "div.summarized-on", + {}, + I18n.t("summary.summarized_on", { date: state.summarized_on }) + ) + ); + } else { + html.push(this.attach("summary-skeleton")); + this.fetchSummary(attrs.topicId); + } + + return html; + }, + + fetchSummary(topicId) { + ajax(`/t/${topicId}/strategy-summary`) + .then((data) => { + this.state.summarized_on = shortDateNoYear(data.summarized_on); + + cookAsync(data.summary).then((cooked) => { + this.state.summary = cooked.string; + this.scheduleRerender(); + }); + }) + .catch(popupAjaxError); + }, +}); diff --git a/app/assets/javascripts/discourse/app/widgets/toggle-topic-summary.js b/app/assets/javascripts/discourse/app/widgets/toggle-topic-summary.js index bcbbb7de409..fbd84248f8b 100644 --- a/app/assets/javascripts/discourse/app/widgets/toggle-topic-summary.js +++ b/app/assets/javascripts/discourse/app/widgets/toggle-topic-summary.js @@ -34,15 +34,36 @@ createWidget("toggle-summary-description", { export default createWidget("toggle-topic-summary", { tagName: "section.information.toggle-summary", - html(attrs) { + buildKey: (attrs) => `toggle-topic-summary-${attrs.topicId}`, + + defaultState() { + return { expandSummaryBox: false }; + }, + + html(attrs, state) { const html = []; const summarizationButtons = []; + if (attrs.summarizable) { + 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: "expandSummaryBox", + disabled: state.expandSummaryBox, + }) + ); + } + if (attrs.hasTopRepliesSummary) { html.push(this.attach("toggle-summary-description", attrs)); summarizationButtons.push( this.attach("button", { - className: "btn btn-primary", + className: "btn top-replies", icon: attrs.topicSummaryEnabled ? null : "layer-group", title: attrs.topicSummaryEnabled ? null : "summary.short_title", label: attrs.topicSummaryEnabled @@ -53,22 +74,18 @@ export default createWidget("toggle-topic-summary", { ); } - 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", - }) - ); + if (summarizationButtons) { + html.push(h("div.summarization-buttons", summarizationButtons)); } - html.push(h("div.summarization-buttons", summarizationButtons)); + if (state.expandSummaryBox) { + html.push(this.attach("summary-box", attrs)); + } return html; }, + + expandSummaryBox() { + this.state.expandSummaryBox = true; + }, }); diff --git a/app/assets/javascripts/discourse/app/widgets/topic-map.js b/app/assets/javascripts/discourse/app/widgets/topic-map.js index 6b94820fdc8..67d6bdd323d 100644 --- a/app/assets/javascripts/discourse/app/widgets/topic-map.js +++ b/app/assets/javascripts/discourse/app/widgets/topic-map.js @@ -386,8 +386,7 @@ export default createWidget("topic-map", { contents.push(this.attach("topic-map-expanded", attrs)); } - if (attrs.hasTopRepliesSummary || this._includesSummary()) { - attrs.includeSummary = this._includesSummary(); + if (attrs.hasTopRepliesSummary || attrs.summarizable) { contents.push(this.attach("toggle-topic-summary", attrs)); } @@ -400,19 +399,4 @@ export default createWidget("topic-map", { toggleMap() { 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) - ) - ); - }, }); diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss index a3c7fe5c4d2..40713ae0d41 100644 --- a/app/assets/stylesheets/common/base/topic-post.scss +++ b/app/assets/stylesheets/common/base/topic-post.scss @@ -843,6 +843,21 @@ aside.quote { .summarization-buttons { display: flex; } + + .placeholder-summary { + padding-top: 0.5em; + } + + .placeholder-summary-text { + display: inline-block; + height: 1em; + margin-top: 0.6em; + width: 100%; + } + + .summarized-on { + text-align: right; + } } } diff --git a/app/assets/stylesheets/desktop/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss index e8879adbd7b..16ec5f82095 100644 --- a/app/assets/stylesheets/desktop/topic-post.scss +++ b/app/assets/stylesheets/desktop/topic-post.scss @@ -326,9 +326,13 @@ pre.codeblock-buttons:hover { } } - .toggle-summary .summarization-buttons .topic-strategy-summarization { + .toggle-summary .summarization-buttons .top-replies { margin-left: 10px; } + + .toggle-summary .summary-box { + margin-top: 10px; + } } #topic-footer-buttons { diff --git a/app/assets/stylesheets/mobile/topic-post.scss b/app/assets/stylesheets/mobile/topic-post.scss index ff3cc54ac7b..2d6db363904 100644 --- a/app/assets/stylesheets/mobile/topic-post.scss +++ b/app/assets/stylesheets/mobile/topic-post.scss @@ -204,7 +204,7 @@ a.reply-to-tab { .summarization-buttons { flex-direction: column; - .topic-strategy-summarization { + .top-replies { margin-top: 10px; } } diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index 3bd7beb72c6..d4b112679b4 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -30,7 +30,6 @@ class TopicsController < ApplicationController publish reset_bump_date set_slow_mode - summary ] before_action :consider_user_for_promotion, only: :show @@ -1172,13 +1171,17 @@ class TopicsController < ApplicationController 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) + if strategy.nil? || !Summarization::Base.can_see_summary?(topic, current_user) + raise Discourse::NotFound + end - RateLimiter.new(current_user, "summary", 6, 5.minutes).performed! + RateLimiter.new(current_user, "summary", 6, 5.minutes).performed! if current_user - hijack { render json: { summary: TopicSummarization.new(strategy).summarize(topic) } } + hijack do + summary = TopicSummarization.new(strategy).summarize(topic, current_user) + render json: { summary: summary&.summarized_text, summarized_on: summary&.updated_at } + end end private diff --git a/app/serializers/topic_view_serializer.rb b/app/serializers/topic_view_serializer.rb index d1f66fe93e5..e65181b26b5 100644 --- a/app/serializers/topic_view_serializer.rb +++ b/app/serializers/topic_view_serializer.rb @@ -78,6 +78,7 @@ class TopicViewSerializer < ApplicationSerializer :user_last_posted_at, :is_shared_draft, :slow_mode_enabled_until, + :summarizable, ) has_one :details, serializer: TopicViewDetailsSerializer, root: false, embed: :objects @@ -311,4 +312,8 @@ class TopicViewSerializer < ApplicationSerializer def slow_mode_enabled_until object.topic.slow_mode_topic_timer&.execute_at end + + def summarizable + object.summarizable? + end end diff --git a/app/serializers/web_hook_topic_view_serializer.rb b/app/serializers/web_hook_topic_view_serializer.rb index ff11424db15..4ec6eb280e6 100644 --- a/app/serializers/web_hook_topic_view_serializer.rb +++ b/app/serializers/web_hook_topic_view_serializer.rb @@ -22,6 +22,7 @@ class WebHookTopicViewSerializer < TopicViewSerializer slow_mode_seconds slow_mode_enabled_until bookmarks + summarizable ].each { |attr| define_method("include_#{attr}?") { false } } def include_show_read_indicator? diff --git a/app/services/topic_summarization.rb b/app/services/topic_summarization.rb index b13452a739b..5d26549f78e 100644 --- a/app/services/topic_summarization.rb +++ b/app/services/topic_summarization.rb @@ -5,33 +5,36 @@ class TopicSummarization @strategy = strategy end - def summarize(topic) - DistributedMutex.synchronize("toppic_summarization_#{topic.id}") do - existing_summary = SummarySection.find_by(target: topic, meta_section_id: nil) - return existing_summary.summarized_text if existing_summary + def summarize(topic, user) + existing_summary = SummarySection.find_by(target: topic, meta_section_id: nil) - content = { - resource_path: "#{Discourse.base_path}/t/-/#{topic.id}", - content_title: topic.title, - contents: [], - } + # 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. + return existing_summary if !user || !Summarization::Base.can_request_summary_for?(user) - targets_data = summary_targets(topic).pluck(:post_number, :raw, :username) + return existing_summary if existing_summary && fresh?(existing_summary, topic) - targets_data.map do |(pn, raw, username)| - content[:contents] << { poster: username, id: pn, text: raw } - end + delete_cached_summaries_of(topic) if existing_summary - summarization_result = strategy.summarize(content) + content = { + resource_path: "#{Discourse.base_path}/t/-/#{topic.id}", + content_title: topic.title, + contents: [], + } - cached_summary(summarization_result, targets_data.map(&:first), topic) + targets_data = summary_targets(topic).pluck(:post_number, :raw, :username) - summarization_result[:summary] + targets_data.map do |(pn, raw, username)| + content[:contents] << { poster: username, id: pn, text: raw } end + + summarization_result = strategy.summarize(content) + + cache_summary(summarization_result, targets_data.map(&:first), topic) end def summary_targets(topic) - topic.has_summary? ? best_replies(topic) : pick_selection(topic) + @targets ||= topic.has_summary? ? best_replies(topic) : pick_selection(topic) end private @@ -66,7 +69,18 @@ class TopicSummarization .order(:post_number) end - def cached_summary(result, post_numbers, topic) + def delete_cached_summaries_of(topic) + SummarySection.where(target: topic).destroy_all + end + + def fresh?(summary, topic) + return true if summary.created_at > 12.hours.ago + latest_post_to_summarize = summary_targets(topic).last.post_number + + latest_post_to_summarize <= summary.content_range.to_a.last + end + + def cache_summary(result, post_numbers, topic) main_summary = SummarySection.create!( target: topic, @@ -86,5 +100,7 @@ class TopicSummarization meta_section_id: main_summary.id, ) end + + main_summary end end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index c5736c637f0..a2095c38e0d 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -2040,7 +2040,9 @@ en: refresh_page: "Refresh page" summary: - enabled_description: "You're viewing a summary of this topic: the most interesting posts as determined by the community." + in_progress: "Summary in progress..." + summarized_on: "Summarized on %{date}" + enabled_description: "You're viewing this topic top replies: the most interesting posts as determined by the community." description: one: "There is %{count} reply." other: "There are %{count} replies." @@ -2057,8 +2059,8 @@ en: enable: "Show top replies" disable: "Show All Posts" - short_label: "Summarize" - short_title: "Show a summary of this topic: the most interesting posts as determined by the community" + short_label: "Top replies" + short_title: "Show this topic top replies: the most interesting posts as determined by the community" strategy: button_title: "Summarize this topic" title: "Topic summary" @@ -3633,7 +3635,7 @@ en: filtered_replies: viewing_posts_by: "Viewing %{post_count} posts by" viewing_subset: "Some replies are collapsed" - viewing_summary: "Viewing a summary of this topic" + viewing_summary: "Viewing this topic top replies" post_number: "%{username}, post #%{post_number}" show_all: "Show all" diff --git a/lib/summarization/base.rb b/lib/summarization/base.rb index e70ac1c2a57..53fbba5ec1e 100644 --- a/lib/summarization/base.rb +++ b/lib/summarization/base.rb @@ -21,13 +21,24 @@ module Summarization find_strategy(SiteSetting.summarization_strategy) end - end - def can_request_summaries?(user) - user_group_ids = user.group_ids + def can_see_summary?(target, user) + return false if SiteSetting.summarization_strategy.blank? - SiteSetting.custom_summarization_allowed_groups_map.any? do |group_id| - user_group_ids.include?(group_id) + has_cached_summary = SummarySection.exists?(target: target, meta_section_id: nil) + return has_cached_summary if user.nil? + + has_cached_summary || can_request_summary_for?(user) + end + + def can_request_summary_for?(user) + return false unless 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 end diff --git a/lib/topic_view.rb b/lib/topic_view.rb index dd6fe4689e8..4a5474f4558 100644 --- a/lib/topic_view.rb +++ b/lib/topic_view.rb @@ -722,6 +722,10 @@ class TopicView end end + def summarizable? + Summarization::Base.can_see_summary?(@topic, @user) + end + protected def read_posts_set diff --git a/plugins/chat/app/controllers/chat/api/summaries_controller.rb b/plugins/chat/app/controllers/chat/api/summaries_controller.rb index 2070b6128bc..208cf98c11b 100644 --- a/plugins/chat/app/controllers/chat/api/summaries_controller.rb +++ b/plugins/chat/app/controllers/chat/api/summaries_controller.rb @@ -12,22 +12,29 @@ class Chat::Api::SummariesController < Chat::ApiController strategy = Summarization::Base.selected_strategy raise Discourse::NotFound.new unless strategy - raise Discourse::InvalidAccess unless strategy.can_request_summaries?(current_user) + raise Discourse::InvalidAccess unless Summarization::Base.can_request_summary_for?(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") + content = { content_title: channel.name } - render json: { summary: strategy.summarize(content).dig(:summary) } + content[:contents] = channel + .chat_messages + .where("chat_messages.created_at > ?", since.hours.ago) + .includes(:user) + .order(created_at: :asc) + .pluck(:id, :username_lower, :message) + .map { { id: _1, poster: _2, text: _3 } } + + summarized_text = + if content[:contents].empty? + I18n.t("chat.summaries.no_targets") + else + strategy.summarize(content).dig(:summary) + end + + render json: { summary: summarized_text } end end end diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/modal/channel-summary.js b/plugins/chat/assets/javascripts/discourse/components/chat/modal/channel-summary.js index 199118729d4..affee4658af 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat/modal/channel-summary.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat/modal/channel-summary.js @@ -27,6 +27,7 @@ export default class ChatModalChannelSummary extends Component { @action summarize(since) { + this.sinceHours = since; this.loading = true; if (this.availableSummaries[since]) { diff --git a/plugins/chat/config/locales/server.en.yml b/plugins/chat/config/locales/server.en.yml index cab3325b153..8e4cbabb7c1 100644 --- a/plugins/chat/config/locales/server.en.yml +++ b/plugins/chat/config/locales/server.en.yml @@ -159,6 +159,9 @@ en: one: "and %{count} other" other: "and %{count} others" + summaries: + no_targets: "There were no messages during the selected period." + discourse_push_notifications: popup: chat_mention: diff --git a/spec/lib/summarization/base_spec.rb b/spec/lib/summarization/base_spec.rb index 489dd1a63ec..34021e07445 100644 --- a/spec/lib/summarization/base_spec.rb +++ b/spec/lib/summarization/base_spec.rb @@ -1,24 +1,67 @@ # frozen_string_literal: true describe Summarization::Base do - subject(:summarization) { described_class.new } - fab!(:user) { Fabricate(:user) } fab!(:group) { Fabricate(:group) } + fab!(:topic) { Fabricate(:topic) } - before { group.add(user) } + let(:plugin) { Plugin::Instance.new } - 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 + before do + group.add(user) - expect(summarization.can_request_summaries?(user)).to eq(true) + strategy = DummyCustomSummarization.new("dummy") + plugin.register_summarization_strategy(strategy) + SiteSetting.summarization_strategy = strategy.model + end + + describe "#can_see_summary?" do + context "when the user cannot generate a summary" do + before { SiteSetting.custom_summarization_allowed_groups = "" } + + it "returns false" do + SiteSetting.custom_summarization_allowed_groups = "" + + expect(described_class.can_see_summary?(topic, user)).to eq(false) + end + + it "returns true if there is a cached summary" do + SummarySection.create!( + target: topic, + summarized_text: "test", + original_content_sha: "123", + algorithm: "test", + meta_section_id: nil, + ) + + expect(described_class.can_see_summary?(topic, user)).to eq(true) + end 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 = "" + context "when the user can generate a summary" do + before { SiteSetting.custom_summarization_allowed_groups = group.id } - expect(summarization.can_request_summaries?(user)).to eq(false) + it "returns true if the user group is present in the custom_summarization_allowed_groups_map setting" do + expect(described_class.can_see_summary?(topic, user)).to eq(true) + end + end + + context "when there is no user" do + it "returns false for anons" do + expect(described_class.can_see_summary?(topic, nil)).to eq(false) + end + + it "returns true for anons when there is a cached summary" do + SummarySection.create!( + target: topic, + summarized_text: "test", + original_content_sha: "123", + algorithm: "test", + meta_section_id: nil, + ) + + expect(described_class.can_see_summary?(topic, nil)).to eq(true) + end end end end diff --git a/spec/requests/api/schemas/json/topic_show_response.json b/spec/requests/api/schemas/json/topic_show_response.json index 40e5bd5d16c..11923b92fee 100644 --- a/spec/requests/api/schemas/json/topic_show_response.json +++ b/spec/requests/api/schemas/json/topic_show_response.json @@ -715,6 +715,9 @@ "null" ] }, + "summarizable": { + "type": "boolean" + }, "details": { "type": "object", "additionalProperties": false, @@ -985,6 +988,7 @@ "show_read_indicator", "thumbnails", "slow_mode_enabled_until", + "summarizable", "details" ] } diff --git a/spec/requests/topics_controller_spec.rb b/spec/requests/topics_controller_spec.rb index d7f90ba6399..b5ae4f14bdd 100644 --- a/spec/requests/topics_controller_spec.rb +++ b/spec/requests/topics_controller_spec.rb @@ -5469,10 +5469,27 @@ RSpec.describe TopicsController do end context "for anons" do - it "returns a 404" do + it "returns a 404 if there is no cached summary" do get "/t/#{topic.id}/strategy-summary.json" - expect(response.status).to eq(403) + expect(response.status).to eq(404) + end + + it "returns a cached summary" do + section = + SummarySection.create!( + target: topic, + summarized_text: "test", + algorithm: "test", + original_content_sha: "test", + ) + + get "/t/#{topic.id}/strategy-summary.json" + + expect(response.status).to eq(200) + + summary = response.parsed_body + expect(summary["summary"]).to eq(section.summarized_text) end end @@ -5498,15 +5515,32 @@ RSpec.describe TopicsController do end end - context "when the user is not a member of an allowlited group" do + context "when the user is not a member of an allowlisted group" do fab!(:user) { Fabricate(:user) } before { sign_in(user) } - it "return a 404" do + it "return a 404 if there is no cached summary" do get "/t/#{topic.id}/strategy-summary.json" - expect(response.status).to eq(403) + expect(response.status).to eq(404) + end + + it "returns a cached summary" do + section = + SummarySection.create!( + target: topic, + summarized_text: "test", + algorithm: "test", + original_content_sha: "test", + ) + + get "/t/#{topic.id}/strategy-summary.json" + + expect(response.status).to eq(200) + + summary = response.parsed_body + expect(summary["summary"]).to eq(section.summarized_text) end end end diff --git a/spec/services/topic_summarization_spec.rb b/spec/services/topic_summarization_spec.rb index ace381bd4fa..aff3b8cc817 100644 --- a/spec/services/topic_summarization_spec.rb +++ b/spec/services/topic_summarization_spec.rb @@ -2,6 +2,7 @@ describe TopicSummarization do fab!(:topic) { Fabricate(:topic) } + fab!(:user) { Fabricate(:admin) } fab!(:post_1) { Fabricate(:post, topic: topic) } fab!(:post_2) { Fabricate(:post, topic: topic) } @@ -79,24 +80,25 @@ describe TopicSummarization do let(:summary) { { summary: "This is the final summary", chunks: [] } } it "caches the summary" do - summarized_text = summarization.summarize(topic) + section = summarization.summarize(topic, user) - expect(summarized_text).to eq(summary[:summary]) + expect(section.summarized_text).to eq(summary[:summary]) assert_summary_is_cached(topic, summary) end it "returns the cached version in subsequent calls" do - summarization.summarize(topic) + summarization.summarize(topic, user) cached_summary_text = "This is a cached summary" cached_summary = SummarySection.find_by(target: topic, meta_section_id: nil).update!( summarized_text: cached_summary_text, + updated_at: 24.hours.ago, ) - summarized_text = summarization.summarize(topic) - expect(summarized_text).to eq(cached_summary_text) + section = summarization.summarize(topic, user) + expect(section.summarized_text).to eq(cached_summary_text) end end @@ -112,14 +114,64 @@ describe TopicSummarization do end it "caches the summary and each chunk" do - summarized_text = summarization.summarize(topic) + section = summarization.summarize(topic, user) - expect(summarized_text).to eq(summary[:summary]) + expect(section.summarized_text).to eq(summary[:summary]) assert_summary_is_cached(topic, summary) summary[:chunks].each { |c| assert_chunk_is_cached(topic, c) } end end + + describe "invalidating cached summaries" do + let(:cached_text) { "This is a cached summary" } + let(:summarized_text) { "This is the final summary" } + let(:summary) do + { + summary: summarized_text, + chunks: [ + { ids: [topic.first_post.post_number], summary: "this is the first chunk" }, + { ids: [post_1.post_number, post_2.post_number], summary: "this is the second chunk" }, + ], + } + end + + def cached_summary + SummarySection.find_by(target: topic, meta_section_id: nil) + end + + before do + summarization.summarize(topic, user) + + cached_summary.update!(summarized_text: cached_text, created_at: 24.hours.ago) + end + + context "when the summary targets changed" do + before { cached_summary.update!(content_range: (1..1)) } + + it "deletes existing summaries and create a new one" do + section = summarization.summarize(topic, user) + + expect(section.summarized_text).to eq(summarized_text) + end + + it "does nothing if the last summary is less than 12 hours old" do + cached_summary.update!(created_at: 6.hours.ago) + + section = summarization.summarize(topic, user) + + expect(section.summarized_text).to eq(cached_text) + end + end + + context "when the summary targets are still the same" do + it "doesn't create a new summary" do + section = summarization.summarize(topic, user) + + expect(section.summarized_text).to eq(cached_text) + end + end + end end end diff --git a/spec/system/topic_summarization_spec.rb b/spec/system/topic_summarization_spec.rb index ff1ff1992c8..f7407484b4c 100644 --- a/spec/system/topic_summarization_spec.rb +++ b/spec/system/topic_summarization_spec.rb @@ -10,7 +10,8 @@ RSpec.describe "Topic summarization", type: :system, js: true do let(:plugin) { Plugin::Instance.new } - let(:summarization_result) { { summary: "This is a summary", chunks: [] } } + let(:expected_summary) { "This is a summary" } + let(:summarization_result) { { summary: expected_summary, chunks: [] } } before do sign_in(user) @@ -22,8 +23,10 @@ RSpec.describe "Topic summarization", type: :system, js: true do it "returns a summary using the selected timeframe" do visit("/t/-/#{topic.id}") - find(".topic-strategy-summarization", wait: 5).click + find(".topic-strategy-summarization").click - expect(page.has_css?(".topic-summary-modal", wait: 5)).to eq(true) + summary = find(".summary-box p").text + + expect(summary).to eq(expected_summary) end end