From 1b0ba9197c6063ef0fe4db7bab534d433edd7acf Mon Sep 17 00:00:00 2001 From: Keegan George Date: Tue, 2 Jul 2024 08:51:59 -0700 Subject: [PATCH] DEV: Add summarization logic from core (#658) --- .../summarization/chat_summary_controller.rb | 49 +++++ .../summarization/summary_controller.rb | 42 ++++ app/jobs/regular/stream_topic_ai_summary.rb | 52 +++++ app/models/ai_summary.rb | 28 +++ .../ai_topic_summary_serializer.rb | 22 ++ .../discourse_ai/topic_summarization.rb | 120 ++++++++++ .../components/ai-summary-skeleton.gjs | 129 +++++++++++ .../modal/chat-modal-channel-summary.gjs | 83 +++++++ .../ai-summary-box.gjs | 207 ++++++++++++++++++ .../discourse/lib/ai-topic-summary.js | 72 ++++++ .../initializers/ai-chat-summarization.js | 29 +++ .../summarization/common/ai-summary.scss | 193 ++++++++++++++++ config/locales/client.en.yml | 9 + config/locales/server.en.yml | 4 + config/routes.rb | 5 + config/settings.yml | 9 + ...0240606151348_create_ai_summaries_table.rb | 15 ++ ...7_copy_summary_sections_to_ai_summaries.rb | 16 ++ ...n_strategy_to_ai_summarization_strategy.rb | 17 ++ ..._ai_custom_summarization_allowed_groups.rb | 19 ++ lib/completions/endpoints/canned_response.rb | 12 +- lib/configuration/summarization_enumerator.rb | 20 ++ lib/configuration/summarization_validator.rb | 23 ++ lib/summarization/entry_point.rb | 74 +------ lib/summarization/models/base.rb | 127 +++++++++++ lib/summarization/models/fake.rb | 25 +++ lib/summarization/strategies/fold_content.rb | 109 +-------- plugin.rb | 2 + .../regular/stream_topic_ai_summary_spec.rb | 81 +++++++ spec/lib/modules/summarization/base_spec.rb | 70 ++++++ .../strategies/fold_content_spec.rb | 32 --- .../chat_summary_controller_spec.rb | 29 +++ .../summarization/summary_controller_spec.rb | 122 +++++++++++ .../discourse_ai/topic_summarization_spec.rb | 199 +++++++++++++++++ .../summarization/chat_summarization_spec.rb | 38 ++++ .../acceptance/topic-summary-test.js | 110 ++++++++++ 36 files changed, 1987 insertions(+), 206 deletions(-) create mode 100644 app/controllers/discourse_ai/summarization/chat_summary_controller.rb create mode 100644 app/controllers/discourse_ai/summarization/summary_controller.rb create mode 100644 app/jobs/regular/stream_topic_ai_summary.rb create mode 100644 app/models/ai_summary.rb create mode 100644 app/serializers/ai_topic_summary_serializer.rb create mode 100644 app/services/discourse_ai/topic_summarization.rb create mode 100644 assets/javascripts/discourse/components/ai-summary-skeleton.gjs create mode 100644 assets/javascripts/discourse/components/modal/chat-modal-channel-summary.gjs create mode 100644 assets/javascripts/discourse/connectors/topic-map-expanded-after/ai-summary-box.gjs create mode 100644 assets/javascripts/discourse/lib/ai-topic-summary.js create mode 100644 assets/javascripts/initializers/ai-chat-summarization.js create mode 100644 assets/stylesheets/modules/summarization/common/ai-summary.scss create mode 100644 db/migrate/20240606151348_create_ai_summaries_table.rb create mode 100644 db/post_migrate/20240606152117_copy_summary_sections_to_ai_summaries.rb create mode 100644 db/post_migrate/20240610232040_copy_summarization_strategy_to_ai_summarization_strategy.rb create mode 100644 db/post_migrate/20240610232546_copy_custom_summarization_allowed_groups_to_ai_custom_summarization_allowed_groups.rb create mode 100644 lib/configuration/summarization_enumerator.rb create mode 100644 lib/configuration/summarization_validator.rb create mode 100644 lib/summarization/models/fake.rb create mode 100644 spec/jobs/regular/stream_topic_ai_summary_spec.rb create mode 100644 spec/lib/modules/summarization/base_spec.rb create mode 100644 spec/requests/summarization/chat_summary_controller_spec.rb create mode 100644 spec/requests/summarization/summary_controller_spec.rb create mode 100644 spec/services/discourse_ai/topic_summarization_spec.rb create mode 100644 spec/system/summarization/chat_summarization_spec.rb create mode 100644 test/javascripts/acceptance/topic-summary-test.js diff --git a/app/controllers/discourse_ai/summarization/chat_summary_controller.rb b/app/controllers/discourse_ai/summarization/chat_summary_controller.rb new file mode 100644 index 00000000..78d51be4 --- /dev/null +++ b/app/controllers/discourse_ai/summarization/chat_summary_controller.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module DiscourseAi + module Summarization + class ChatSummaryController < ::Chat::ApiController + requires_plugin ::DiscourseAi::PLUGIN_NAME + requires_plugin ::Chat::PLUGIN_NAME + + VALID_SINCE_VALUES = [1, 3, 6, 12, 24, 72, 168] + + def show + since = params[:since].to_i + raise Discourse::InvalidParameters.new(:since) if !VALID_SINCE_VALUES.include?(since) + + channel = ::Chat::Channel.find(params[:channel_id]) + guardian.ensure_can_join_chat_channel!(channel) + + strategy = DiscourseAi::Summarization::Models::Base.selected_strategy + raise Discourse::NotFound.new unless strategy + unless DiscourseAi::Summarization::Models::Base.can_request_summary_for?(current_user) + raise Discourse::InvalidAccess + end + + RateLimiter.new(current_user, "channel_summary", 6, 5.minutes).performed! + + hijack do + content = { content_title: channel.name } + + 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("discourse_ai.summarization.chat.no_targets") + else + strategy.summarize(content, current_user).dig(:summary) + end + + render json: { summary: summarized_text } + end + end + end + end +end diff --git a/app/controllers/discourse_ai/summarization/summary_controller.rb b/app/controllers/discourse_ai/summarization/summary_controller.rb new file mode 100644 index 00000000..48c8b7e0 --- /dev/null +++ b/app/controllers/discourse_ai/summarization/summary_controller.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module DiscourseAi + module Summarization + class SummaryController < ::ApplicationController + requires_plugin ::DiscourseAi::PLUGIN_NAME + + def show + topic = Topic.find(params[:topic_id]) + guardian.ensure_can_see!(topic) + strategy = DiscourseAi::Summarization::Models::Base.selected_strategy + + if strategy.nil? || + !DiscourseAi::Summarization::Models::Base.can_see_summary?(topic, current_user) + raise Discourse::NotFound + end + + RateLimiter.new(current_user, "summary", 6, 5.minutes).performed! if current_user + + opts = params.permit(:skip_age_check) + + if params[:stream] && current_user + Jobs.enqueue( + :stream_topic_ai_summary, + topic_id: topic.id, + user_id: current_user.id, + opts: opts.as_json, + ) + + render json: success_json + else + hijack do + summary = + DiscourseAi::TopicSummarization.new(strategy).summarize(topic, current_user, opts) + + render_serialized(summary, AiTopicSummarySerializer) + end + end + end + end + end +end diff --git a/app/jobs/regular/stream_topic_ai_summary.rb b/app/jobs/regular/stream_topic_ai_summary.rb new file mode 100644 index 00000000..1dce0925 --- /dev/null +++ b/app/jobs/regular/stream_topic_ai_summary.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Jobs + class StreamTopicAiSummary < ::Jobs::Base + sidekiq_options retry: false + + def execute(args) + return unless topic = Topic.find_by(id: args[:topic_id]) + return unless user = User.find_by(id: args[:user_id]) + + strategy = DiscourseAi::Summarization::Models::Base.selected_strategy + if strategy.nil? || !DiscourseAi::Summarization::Models::Base.can_see_summary?(topic, user) + return + end + + guardian = Guardian.new(user) + return unless guardian.can_see?(topic) + + opts = args[:opts] || {} + + streamed_summary = +"" + start = Time.now + + summary = + DiscourseAi::TopicSummarization + .new(strategy) + .summarize(topic, user, opts) do |partial_summary| + streamed_summary << partial_summary + + # Throttle updates. + if (Time.now - start > 0.5) || Rails.env.test? + payload = { done: false, ai_topic_summary: { summarized_text: streamed_summary } } + + publish_update(topic, user, payload) + start = Time.now + end + end + + publish_update( + topic, + user, + AiTopicSummarySerializer.new(summary, { scope: guardian }).as_json.merge(done: true), + ) + end + + private + + def publish_update(topic, user, payload) + MessageBus.publish("/discourse-ai/summaries/topic/#{topic.id}", payload, user_ids: [user.id]) + end + end +end diff --git a/app/models/ai_summary.rb b/app/models/ai_summary.rb new file mode 100644 index 00000000..027dd5ce --- /dev/null +++ b/app/models/ai_summary.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class AiSummary < ActiveRecord::Base + belongs_to :target, polymorphic: true + + def mark_as_outdated + @outdated = true + end + + def outdated + @outdated || false + end +end + +# == Schema Information +# +# Table name: ai_summaries +# +# id :bigint not null, primary key +# target_id :integer not null +# target_type :string not null +# content_range :int4range +# summarized_text :string not null +# original_content_sha :string not null +# algorithm :string not null +# created_at :datetime not null +# updated_at :datetime not null +# diff --git a/app/serializers/ai_topic_summary_serializer.rb b/app/serializers/ai_topic_summary_serializer.rb new file mode 100644 index 00000000..04846193 --- /dev/null +++ b/app/serializers/ai_topic_summary_serializer.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class AiTopicSummarySerializer < ApplicationSerializer + attributes :summarized_text, :algorithm, :outdated, :can_regenerate, :new_posts_since_summary + + def can_regenerate + DiscourseAi::Summarization::Models::Base.can_request_summary_for?(scope.current_user) + end + + def new_posts_since_summary + # Postgres uses discrete range types for int4range, which means + # (1..2) is stored as (1...3). + # + # We use Range#max to work around this, which in the case above always returns 2. + # Be careful with using Range#end here, it could lead to unexpected results as: + # + # (1..2).end => 2 + # (1...3).end => 3 + + object.target.highest_post_number.to_i - object.content_range&.max.to_i + end +end diff --git a/app/services/discourse_ai/topic_summarization.rb b/app/services/discourse_ai/topic_summarization.rb new file mode 100644 index 00000000..47051694 --- /dev/null +++ b/app/services/discourse_ai/topic_summarization.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +module DiscourseAi + class TopicSummarization + def initialize(strategy) + @strategy = strategy + end + + def summarize(topic, user, opts = {}, &on_partial_blk) + existing_summary = AiSummary.find_by(target: topic) + + # Existing summary shouldn't be nil in this scenario because the controller checks its existence. + return if !user && !existing_summary + + targets_data = summary_targets(topic).pluck(:post_number, :raw, :username) + + current_topic_sha = build_sha(targets_data.map(&:first)) + can_summarize = DiscourseAi::Summarization::Models::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 + existing_summary.mark_as_outdated if new_targets?(existing_summary, current_topic_sha) + + return existing_summary + end + + delete_cached_summaries_of(topic) if existing_summary + + content = { + resource_path: "#{Discourse.base_path}/t/-/#{topic.id}", + content_title: topic.title, + contents: [], + } + + targets_data.map do |(pn, raw, username)| + raw_text = raw + + if pn == 1 && topic.topic_embed&.embed_content_cache.present? + raw_text = topic.topic_embed&.embed_content_cache + end + + content[:contents] << { poster: username, id: pn, text: raw_text } + end + + summarization_result = strategy.summarize(content, user, &on_partial_blk) + + cache_summary(summarization_result, targets_data.map(&:first), topic) + end + + def summary_targets(topic) + topic.has_summary? ? best_replies(topic) : pick_selection(topic) + end + + private + + attr_reader :strategy + + def best_replies(topic) + Post + .summary(topic.id) + .where("post_type = ?", Post.types[:regular]) + .where("NOT hidden") + .joins(:user) + .order(:post_number) + end + + def pick_selection(topic) + posts = + Post + .where(topic_id: topic.id) + .where("post_type = ?", Post.types[:regular]) + .where("NOT hidden") + .order(:post_number) + + post_numbers = posts.limit(5).pluck(:post_number) + post_numbers += posts.reorder("posts.score desc").limit(50).pluck(:post_number) + post_numbers += posts.reorder("post_number desc").limit(5).pluck(:post_number) + + Post + .where(topic_id: topic.id) + .joins(:user) + .where("post_number in (?)", post_numbers) + .order(:post_number) + end + + def delete_cached_summaries_of(topic) + AiSummary.where(target: topic).destroy_all + end + + # For users without permissions to generate a summary or fresh summaries, we return what we have cached. + def use_cached?(existing_summary, can_summarize, current_sha, skip_age_check) + existing_summary && + !( + can_summarize && new_targets?(existing_summary, current_sha) && + (skip_age_check || existing_summary.created_at < 1.hour.ago) + ) + end + + def new_targets?(summary, current_sha) + summary.original_content_sha != current_sha + end + + def cache_summary(result, post_numbers, topic) + cached_summary = + AiSummary.create!( + target: topic, + algorithm: strategy.display_name, + content_range: (post_numbers.first..post_numbers.last), + summarized_text: result[:summary], + original_content_sha: build_sha(post_numbers), + ) + + cached_summary + end + + def build_sha(ids) + Digest::SHA256.hexdigest(ids.join) + end + end +end diff --git a/assets/javascripts/discourse/components/ai-summary-skeleton.gjs b/assets/javascripts/discourse/components/ai-summary-skeleton.gjs new file mode 100644 index 00000000..a09daa6f --- /dev/null +++ b/assets/javascripts/discourse/components/ai-summary-skeleton.gjs @@ -0,0 +1,129 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { fn } from "@ember/helper"; +import { action } from "@ember/object"; +import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import didUpdate from "@ember/render-modifiers/modifiers/did-update"; +import willDestroy from "@ember/render-modifiers/modifiers/will-destroy"; +import { cancel } from "@ember/runloop"; +import concatClass from "discourse/helpers/concat-class"; +import i18n from "discourse-common/helpers/i18n"; +import discourseLater from "discourse-common/lib/later"; + +class Block { + @tracked show = false; + @tracked shown = false; + @tracked blinking = false; + + constructor(args = {}) { + this.show = args.show ?? false; + this.shown = args.shown ?? false; + } +} + +const BLOCKS_SIZE = 20; // changing this requires to change css accordingly + +export default class AiSummarySkeleton extends Component { + blocks = [...Array.from({ length: BLOCKS_SIZE }, () => new Block())]; + + #nextBlockBlinkingTimer; + #blockBlinkingTimer; + #blockShownTimer; + + @action + setupAnimation() { + this.blocks.firstObject.show = true; + this.blocks.firstObject.shown = true; + } + + @action + onBlinking(block) { + if (!block.blinking) { + return; + } + + block.show = false; + + this.#nextBlockBlinkingTimer = discourseLater( + this, + () => { + this.#nextBlock(block).blinking = true; + }, + 250 + ); + + this.#blockBlinkingTimer = discourseLater( + this, + () => { + block.blinking = false; + }, + 500 + ); + } + + @action + onShowing(block) { + if (!block.show) { + return; + } + + this.#blockShownTimer = discourseLater( + this, + () => { + this.#nextBlock(block).show = true; + this.#nextBlock(block).shown = true; + + if (this.blocks.lastObject === block) { + this.blocks.firstObject.blinking = true; + } + }, + 250 + ); + } + + @action + teardownAnimation() { + cancel(this.#blockShownTimer); + cancel(this.#nextBlockBlinkingTimer); + cancel(this.#blockBlinkingTimer); + } + + #nextBlock(currentBlock) { + if (currentBlock === this.blocks.lastObject) { + return this.blocks.firstObject; + } else { + return this.blocks.objectAt(this.blocks.indexOf(currentBlock) + 1); + } + } + + +} diff --git a/assets/javascripts/discourse/components/modal/chat-modal-channel-summary.gjs b/assets/javascripts/discourse/components/modal/chat-modal-channel-summary.gjs new file mode 100644 index 00000000..a8921f5d --- /dev/null +++ b/assets/javascripts/discourse/components/modal/chat-modal-channel-summary.gjs @@ -0,0 +1,83 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import ConditionalLoadingSection from "discourse/components/conditional-loading-section"; +import DModal from "discourse/components/d-modal"; +import DModalCancel from "discourse/components/d-modal-cancel"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import i18n from "discourse-common/helpers/i18n"; +import I18n from "discourse-i18n"; +import ComboBox from "select-kit/components/combo-box"; + +export default class ChatModalChannelSummary extends Component { + @service chatApi; + + @tracked sinceHours = null; + @tracked loading = false; + @tracked summary = null; + + availableSummaries = {}; + + sinceOptions = [1, 3, 6, 12, 24, 72, 168].map((hours) => { + return { + name: I18n.t("discourse_ai.summarization.chat.since", { count: hours }), + value: hours, + }; + }); + + get channelId() { + return this.args.model.channelId; + } + + @action + summarize(since) { + this.sinceHours = since; + this.loading = true; + + if (this.availableSummaries[since]) { + this.summary = this.availableSummaries[since]; + this.loading = false; + return; + } + + return ajax(`/discourse-ai/summarization/channels/${this.channelId}.json`, { + type: "GET", + data: { + since, + }, + }) + .then((data) => { + this.availableSummaries[this.sinceHours] = data.summary; + this.summary = this.availableSummaries[this.sinceHours]; + }) + .catch(popupAjaxError) + .finally(() => (this.loading = false)); + } + + +} diff --git a/assets/javascripts/discourse/connectors/topic-map-expanded-after/ai-summary-box.gjs b/assets/javascripts/discourse/connectors/topic-map-expanded-after/ai-summary-box.gjs new file mode 100644 index 00000000..70d22aaf --- /dev/null +++ b/assets/javascripts/discourse/connectors/topic-map-expanded-after/ai-summary-box.gjs @@ -0,0 +1,207 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { array } from "@ember/helper"; +import { action } from "@ember/object"; +import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import willDestroy from "@ember/render-modifiers/modifiers/will-destroy"; +import { service } from "@ember/service"; +import DButton from "discourse/components/d-button"; +import { ajax } from "discourse/lib/ajax"; +import { shortDateNoYear } from "discourse/lib/formatter"; +import { cook } from "discourse/lib/text"; +import dIcon from "discourse-common/helpers/d-icon"; +import i18n from "discourse-common/helpers/i18n"; +import { bind } from "discourse-common/utils/decorators"; +import I18n from "discourse-i18n"; +import DTooltip from "float-kit/components/d-tooltip"; +import and from "truth-helpers/helpers/and"; +import not from "truth-helpers/helpers/not"; +import or from "truth-helpers/helpers/or"; +import AiSummarySkeleton from "../../components/ai-summary-skeleton"; + +export default class AiSummaryBox extends Component { + @service siteSettings; + @service messageBus; + @service currentUser; + @tracked summary = ""; + @tracked text = ""; + @tracked summarizedOn = null; + @tracked summarizedBy = null; + @tracked newPostsSinceSummary = null; + @tracked outdated = false; + @tracked canRegenerate = false; + @tracked regenerated = false; + + @tracked showSummaryBox = false; + @tracked canCollapseSummary = false; + @tracked loading = false; + + get generateSummaryTitle() { + const title = this.canRegenerate + ? "summary.buttons.regenerate" + : "summary.buttons.generate"; + + return I18n.t(title); + } + + get generateSummaryIcon() { + return this.canRegenerate ? "sync" : "discourse-sparkles"; + } + + get outdatedSummaryWarningText() { + let outdatedText = I18n.t("summary.outdated"); + + if (!this.topRepliesSummaryEnabled && this.newPostsSinceSummary > 0) { + outdatedText += " "; + outdatedText += I18n.t("summary.outdated_posts", { + count: this.newPostsSinceSummary, + }); + } + + return outdatedText; + } + + get topRepliesSummaryEnabled() { + return this.args.outletArgs.postStream.summary; + } + + @action + collapse() { + this.showSummaryBox = false; + this.canCollapseSummary = false; + } + + @action + generateSummary() { + const topicId = this.args.outletArgs.topic.id; + this.showSummaryBox = true; + + if (this.text && !this.canRegenerate) { + this.canCollapseSummary = false; + return; + } + + let fetchURL = `/discourse-ai/summarization/t/${topicId}?`; + + if (this.currentUser) { + fetchURL += `stream=true`; + + if (this.canRegenerate) { + fetchURL += "&skip_age_check=true"; + } + } + + this.loading = true; + + return ajax(fetchURL).then((data) => { + if (!this.currentUser) { + data.done = true; + this._updateSummary(data); + } + }); + } + + @bind + subscribe() { + const channel = `/discourse-ai/summaries/topic/${this.args.outletArgs.topic.id}`; + this.messageBus.subscribe(channel, this._updateSummary); + } + + @bind + unsubscribe() { + this.messageBus.unsubscribe( + "/discourse-ai/summaries/topic/*", + this._updateSummary + ); + } + + @bind + _updateSummary(update) { + const topicSummary = update.ai_topic_summary; + + return cook(topicSummary.summarized_text) + .then((cooked) => { + this.text = cooked; + this.loading = false; + }) + .then(() => { + if (update.done) { + this.summarizedOn = shortDateNoYear(topicSummary.summarized_on); + this.summarizedBy = topicSummary.algorithm; + this.newPostsSinceSummary = topicSummary.new_posts_since_summary; + this.outdated = topicSummary.outdated; + this.newPostsSinceSummary = topicSummary.new_posts_since_summary; + this.canRegenerate = + topicSummary.outdated && topicSummary.can_regenerate; + } + }); + } + + +} diff --git a/assets/javascripts/discourse/lib/ai-topic-summary.js b/assets/javascripts/discourse/lib/ai-topic-summary.js new file mode 100644 index 00000000..fd2450cf --- /dev/null +++ b/assets/javascripts/discourse/lib/ai-topic-summary.js @@ -0,0 +1,72 @@ +import { tracked } from "@glimmer/tracking"; +import { ajax } from "discourse/lib/ajax"; +import { shortDateNoYear } from "discourse/lib/formatter"; +import { cook } from "discourse/lib/text"; + +export default class AiTopicSummary { + @tracked text = ""; + @tracked summarizedOn = null; + @tracked summarizedBy = null; + @tracked newPostsSinceSummary = null; + @tracked outdated = false; + @tracked canRegenerate = false; + @tracked regenerated = false; + + @tracked showSummaryBox = false; + @tracked canCollapseSummary = false; + @tracked loadingSummary = false; + + processUpdate(update) { + const topicSummary = update.ai_topic_summary; + + return cook(topicSummary.summarized_text) + .then((cooked) => { + this.text = cooked; + this.loading = false; + }) + .then(() => { + if (update.done) { + this.summarizedOn = shortDateNoYear(topicSummary.summarized_on); + this.summarizedBy = topicSummary.algorithm; + this.newPostsSinceSummary = topicSummary.new_posts_since_summary; + this.outdated = topicSummary.outdated; + this.newPostsSinceSummary = topicSummary.new_posts_since_summary; + this.canRegenerate = + topicSummary.outdated && topicSummary.can_regenerate; + } + }); + } + + collapse() { + this.showSummaryBox = false; + this.canCollapseSummary = false; + } + + generateSummary(currentUser, topicId) { + this.showSummaryBox = true; + + if (this.text && !this.canRegenerate) { + this.canCollapseSummary = false; + return; + } + + let fetchURL = `/discourse-ai/summarization/t/${topicId}?`; + + if (currentUser) { + fetchURL += `stream=true`; + + if (this.canRegenerate) { + fetchURL += "&skip_age_check=true"; + } + } + + this.loading = true; + + return ajax(fetchURL).then((data) => { + if (!currentUser) { + data.done = true; + this.processUpdate(data); + } + }); + } +} diff --git a/assets/javascripts/initializers/ai-chat-summarization.js b/assets/javascripts/initializers/ai-chat-summarization.js new file mode 100644 index 00000000..b2ba6e74 --- /dev/null +++ b/assets/javascripts/initializers/ai-chat-summarization.js @@ -0,0 +1,29 @@ +import { apiInitializer } from "discourse/lib/api"; +import ChatModalChannelSummary from "../discourse/components/modal/chat-modal-channel-summary"; + +export default apiInitializer("1.34.0", (api) => { + const siteSettings = api.container.lookup("service:site-settings"); + const currentUser = api.getCurrentUser(); + const chatService = api.container.lookup("service:chat"); + const modal = api.container.lookup("service:modal"); + const canSummarize = + siteSettings.ai_summarization_strategy && + currentUser && + currentUser.can_summarize; + + if (!chatService.userCanChat || !siteSettings.chat_enabled || !canSummarize) { + return; + } + + api.registerChatComposerButton({ + translatedLabel: "discourse_ai.summarization.chat.title", + id: "channel-summary", + icon: "discourse-sparkles", + position: "dropdown", + action: () => { + modal.show(ChatModalChannelSummary, { + model: { channelId: chatService.activeChannel?.id }, + }); + }, + }); +}); diff --git a/assets/stylesheets/modules/summarization/common/ai-summary.scss b/assets/stylesheets/modules/summarization/common/ai-summary.scss new file mode 100644 index 00000000..55d2d010 --- /dev/null +++ b/assets/stylesheets/modules/summarization/common/ai-summary.scss @@ -0,0 +1,193 @@ +.topic-map .toggle-summary { + .summarization-buttons { + display: flex; + gap: 0.5em; + } + + .ai-summary { + &__list { + list-style: none; + display: flex; + flex-wrap: wrap; + padding: 0; + margin: 0; + } + &__list-item { + background: var(--primary-300); + border-radius: var(--d-border-radius); + margin-right: 8px; + margin-bottom: 8px; + height: 18px; + opacity: 0; + display: block; + &:nth-child(1) { + width: 10%; + } + + &:nth-child(2) { + width: 12%; + } + + &:nth-child(3) { + width: 18%; + } + + &:nth-child(4) { + width: 14%; + } + + &:nth-child(5) { + width: 18%; + } + + &:nth-child(6) { + width: 14%; + } + + &:nth-child(7) { + width: 22%; + } + + &:nth-child(8) { + width: 05%; + } + + &:nth-child(9) { + width: 25%; + } + + &:nth-child(10) { + width: 14%; + } + + &:nth-child(11) { + width: 18%; + } + + &:nth-child(12) { + width: 12%; + } + + &:nth-child(13) { + width: 22%; + } + + &:nth-child(14) { + width: 18%; + } + + &:nth-child(15) { + width: 13%; + } + + &:nth-child(16) { + width: 22%; + } + + &:nth-child(17) { + width: 19%; + } + + &:nth-child(18) { + width: 13%; + } + + &:nth-child(19) { + width: 22%; + } + + &:nth-child(20) { + width: 25%; + } + &.is-shown { + opacity: 1; + } + &.show { + animation: appear 0.5s cubic-bezier(0.445, 0.05, 0.55, 0.95) 0s forwards; + @media (prefers-reduced-motion) { + animation-duration: 0s; + } + } + @media (prefers-reduced-motion: no-preference) { + &.blink { + animation: blink 0.5s cubic-bezier(0.55, 0.085, 0.68, 0.53) both; + } + } + } + &__generating-text { + display: inline-block; + margin-left: 3px; + } + &__indicator-wave { + flex: 0 0 auto; + display: inline-flex; + } + &__indicator-dot { + display: inline-block; + @media (prefers-reduced-motion: no-preference) { + animation: ai-summary__indicator-wave 1.8s linear infinite; + } + &:nth-child(2) { + animation-delay: -1.6s; + } + &:nth-child(3) { + animation-delay: -1.4s; + } + } + } + + .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; + + .info-icon { + margin-left: 3px; + } + } + + .outdated-summary { + color: var(--primary-medium); + } +} + +@keyframes ai-summary__indicator-wave { + 0%, + 60%, + 100% { + transform: initial; + } + 30% { + transform: translateY(-0.2em); + } +} + +@keyframes appear { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +@keyframes blink { + 0% { + opacity: 1; + } + 50% { + opacity: 0.5; + } + 100% { + opacity: 1; + } +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index c5126077..1aa41dca 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -391,6 +391,15 @@ en: sentiments: dashboard: title: "Sentiment" + + summarization: + chat: + title: "Summarize messages" + description: "Select an option below to summarize the conversation sent during the desired timeframe." + summarize: "Summarize" + since: + one: "Last hour" + other: "Last %{count} hours" review: types: reviewable_ai_post: diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 06b9169d..6b5d58a1 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -95,6 +95,8 @@ en: ai_summarization_discourse_service_api_endpoint: "URL where the Discourse summarization API is running." ai_summarization_discourse_service_api_key: "API key for the Discourse summarization API." + ai_summarization_strategy: "Additional ways to summarize content registered by plugins" + ai_custom_summarization_allowed_groups: "Groups allowed to summarize contents using the `summarization_strategy`." ai_bot_enabled: "Enable the AI Bot module." ai_bot_enable_chat_warning: "Display a warning when PM chat is initiated. Can be overriden by editing the translation string: discourse_ai.ai_bot.pm_warning" @@ -312,6 +314,8 @@ en: configuration_hint: one: "Configure the `%{setting}` setting first." other: "Configure these settings first: %{settings}" + chat: + no_targets: "There were no messages during the selected period." sentiment: reports: diff --git a/config/routes.rb b/config/routes.rb index 8f621eb1..bf5790d2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -27,6 +27,11 @@ DiscourseAi::Engine.routes.draw do get "/:share_key" => "shared_ai_conversations#show" get "/preview/:topic_id" => "shared_ai_conversations#preview" end + + scope module: :summarization, path: "/summarization", defaults: { format: :json } do + get "/t/:topic_id" => "summary#show", :constraints => { topic_id: /\d+/ } + get "/channels/:channel_id" => "chat_summary#show" + end end Discourse::Application.routes.draw do diff --git a/config/settings.yml b/config/settings.yml index d24917a9..aac0514d 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -327,6 +327,15 @@ discourse_ai: ai_summarization_discourse_service_api_key: default: "" secret: true + ai_summarization_strategy: + client: true + default: "" + enum: "DiscourseAi::Configuration::SummarizationEnumerator" + validator: "DiscourseAi::Configuration::SummarizationValidator" + ai_custom_summarization_allowed_groups: + type: group_list + list_type: compact + default: "3|13" # 3: @staff, 13: @trust_level_3 ai_bot_enabled: default: false diff --git a/db/migrate/20240606151348_create_ai_summaries_table.rb b/db/migrate/20240606151348_create_ai_summaries_table.rb new file mode 100644 index 00000000..bcace654 --- /dev/null +++ b/db/migrate/20240606151348_create_ai_summaries_table.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateAiSummariesTable < ActiveRecord::Migration[7.0] + def change + create_table :ai_summaries do |t| + t.integer :target_id, null: false + t.string :target_type, null: false + t.int4range :content_range + t.string :summarized_text, null: false + t.string :original_content_sha, null: false + t.string :algorithm, null: false + t.timestamps + end + end +end diff --git a/db/post_migrate/20240606152117_copy_summary_sections_to_ai_summaries.rb b/db/post_migrate/20240606152117_copy_summary_sections_to_ai_summaries.rb new file mode 100644 index 00000000..326f78cc --- /dev/null +++ b/db/post_migrate/20240606152117_copy_summary_sections_to_ai_summaries.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CopySummarySectionsToAiSummaries < ActiveRecord::Migration[7.0] + def up + execute <<-SQL + INSERT INTO ai_summaries (id, target_id, target_type, content_range, summarized_text, original_content_sha, algorithm, created_at, updated_at) + SELECT id, target_id, target_type, content_range, summarized_text, original_content_sha, algorithm, created_at, updated_at + FROM summary_sections + WHERE meta_section_id IS NULL + SQL + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/post_migrate/20240610232040_copy_summarization_strategy_to_ai_summarization_strategy.rb b/db/post_migrate/20240610232040_copy_summarization_strategy_to_ai_summarization_strategy.rb new file mode 100644 index 00000000..bbad5955 --- /dev/null +++ b/db/post_migrate/20240610232040_copy_summarization_strategy_to_ai_summarization_strategy.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CopySummarizationStrategyToAiSummarizationStrategy < ActiveRecord::Migration[7.0] + def up + execute <<-SQL + UPDATE site_settings + SET data_type = (SELECT data_type FROM site_settings WHERE name = 'summarization_strategy'), + value = (SELECT value FROM site_settings WHERE name = 'summarization_strategy') + WHERE name = 'ai_summarization_strategy' + AND EXISTS (SELECT 1 FROM site_settings WHERE name = 'summarization_strategy'); + SQL + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/post_migrate/20240610232546_copy_custom_summarization_allowed_groups_to_ai_custom_summarization_allowed_groups.rb b/db/post_migrate/20240610232546_copy_custom_summarization_allowed_groups_to_ai_custom_summarization_allowed_groups.rb new file mode 100644 index 00000000..ae28e389 --- /dev/null +++ b/db/post_migrate/20240610232546_copy_custom_summarization_allowed_groups_to_ai_custom_summarization_allowed_groups.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class CopyCustomSummarizationAllowedGroupsToAiCustomSummarizationAllowedGroups < ActiveRecord::Migration[ + 7.0 +] + def up + execute <<-SQL + UPDATE site_settings + SET data_type = (SELECT data_type FROM site_settings WHERE name = 'custom_summarization_allowed_groups'), + value = (SELECT value FROM site_settings WHERE name = 'custom_summarization_allowed_groups') + WHERE name = 'ai_custom_summarization_allowed_groups' + AND EXISTS (SELECT 1 FROM site_settings WHERE name = 'custom_summarization_allowed_groups'); + SQL + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/lib/completions/endpoints/canned_response.rb b/lib/completions/endpoints/canned_response.rb index 1beed66f..eae930ca 100644 --- a/lib/completions/endpoints/canned_response.rb +++ b/lib/completions/endpoints/canned_response.rb @@ -13,7 +13,7 @@ module DiscourseAi def initialize(responses) @responses = responses @completions = 0 - @prompt = nil + @dialect = nil end def normalize_model_params(model_params) @@ -21,10 +21,14 @@ module DiscourseAi model_params end - attr_reader :responses, :completions, :prompt + attr_reader :responses, :completions, :dialect - def perform_completion!(prompt, _user, _model_params, feature_name: nil) - @prompt = prompt + def prompt_messages + dialect.prompt.messages + end + + def perform_completion!(dialect, _user, _model_params, feature_name: nil) + @dialect = dialect response = responses[completions] if response.nil? raise CANNED_RESPONSE_ERROR, diff --git a/lib/configuration/summarization_enumerator.rb b/lib/configuration/summarization_enumerator.rb new file mode 100644 index 00000000..915991dd --- /dev/null +++ b/lib/configuration/summarization_enumerator.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "enum_site_setting" + +module DiscourseAi + module Configuration + class SummarizationEnumerator < ::EnumSiteSetting + def self.valid_value?(val) + true + end + + def self.values + @values ||= + DiscourseAi::Summarization::Models::Base.available_strategies.map do |strategy| + { name: strategy.display_name, value: strategy.model } + end + end + end + end +end diff --git a/lib/configuration/summarization_validator.rb b/lib/configuration/summarization_validator.rb new file mode 100644 index 00000000..87edbea9 --- /dev/null +++ b/lib/configuration/summarization_validator.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module DiscourseAi + module Configuration + class SummarizationValidator + def initialize(opts = {}) + @opts = opts + end + + def valid_value?(val) + strategy = DiscourseAi::Summarization::Models::Base.find_strategy(val) + + return true unless strategy + + strategy.correctly_configured?.tap { |is_valid| @strategy = strategy unless is_valid } + end + + def error_message + @strategy.configuration_hint + end + end + end +end diff --git a/lib/summarization/entry_point.rb b/lib/summarization/entry_point.rb index 960a2b3e..3dcbd769 100644 --- a/lib/summarization/entry_point.rb +++ b/lib/summarization/entry_point.rb @@ -4,79 +4,17 @@ module DiscourseAi module Summarization class EntryPoint def inject_into(plugin) - foldable_models = [ - Models::OpenAi.new("open_ai:gpt-4", max_tokens: 8192), - Models::OpenAi.new("open_ai:gpt-4-32k", max_tokens: 32_768), - Models::OpenAi.new("open_ai:gpt-4-turbo", max_tokens: 100_000), - Models::OpenAi.new("open_ai:gpt-4o", max_tokens: 100_000), - Models::OpenAi.new("open_ai:gpt-3.5-turbo", max_tokens: 4096), - Models::OpenAi.new("open_ai:gpt-3.5-turbo-16k", max_tokens: 16_384), - Models::Gemini.new("google:gemini-pro", max_tokens: 32_768), - Models::Gemini.new("google:gemini-1.5-pro", max_tokens: 800_000), - Models::Gemini.new("google:gemini-1.5-flash", max_tokens: 800_000), - ] - - claude_prov = "anthropic" - if DiscourseAi::Completions::Endpoints::AwsBedrock.correctly_configured?("claude-2") - claude_prov = "aws_bedrock" + plugin.add_to_serializer(:current_user, :can_summarize) do + scope.user.in_any_groups?(SiteSetting.ai_custom_summarization_allowed_groups_map) end - foldable_models << Models::Anthropic.new("#{claude_prov}:claude-2", max_tokens: 200_000) - foldable_models << Models::Anthropic.new( - "#{claude_prov}:claude-instant-1", - max_tokens: 100_000, - ) - foldable_models << Models::Anthropic.new( - "#{claude_prov}:claude-3-haiku", - max_tokens: 200_000, - ) - foldable_models << Models::Anthropic.new( - "#{claude_prov}:claude-3-sonnet", - max_tokens: 200_000, - ) - - foldable_models << Models::Anthropic.new( - "#{claude_prov}:claude-3-opus", - max_tokens: 200_000, - ) - - mixtral_prov = "hugging_face" - if DiscourseAi::Completions::Endpoints::Vllm.correctly_configured?( - "mistralai/Mixtral-8x7B-Instruct-v0.1", - ) - mixtral_prov = "vllm" + plugin.add_to_serializer(:topic_view, :summarizable) do + DiscourseAi::Summarization::Models::Base.can_see_summary?(object.topic, scope.user) end - foldable_models << Models::Mixtral.new( - "#{mixtral_prov}:mistralai/Mixtral-8x7B-Instruct-v0.1", - max_tokens: 32_000, - ) - - # TODO: Roman, we need to de-register custom LLMs on destroy from summarization - # strategy and clear cache - # it may be better to pull all of this code into Discourse AI cause as it stands - # the coupling is making it really hard to reason about summarization - # - # Auto registration and de-registration needs to be tested - - #LlmModel.all.each do |model| - # foldable_models << Models::CustomLlm.new( - # "custom:#{model.id}", - # max_tokens: model.max_prompt_tokens, - # ) - #end - - foldable_models.each do |model| - plugin.register_summarization_strategy(Strategies::FoldContent.new(model)) + plugin.add_to_serializer(:web_hook_topic_view, :summarizable) do + DiscourseAi::Summarization::Models::Base.can_see_summary?(object.topic, scope.user) end - - #plugin.add_model_callback(LlmModel, :after_create) do - # new_model = Models::CustomLlm.new("custom:#{self.id}", max_tokens: self.max_prompt_tokens) - - # if ::Summarization::Base.find_strategy("custom:#{self.id}").nil? - # plugin.register_summarization_strategy(Strategies::FoldContent.new(new_model)) - # end - #end end end end diff --git a/lib/summarization/models/base.rb b/lib/summarization/models/base.rb index 487950d8..91d0e38f 100644 --- a/lib/summarization/models/base.rb +++ b/lib/summarization/models/base.rb @@ -1,30 +1,157 @@ # frozen_string_literal: true +# Base class that defines the interface that every summarization +# strategy must implement. +# Above each method, you'll find an explanation of what +# it does and what it should return. + module DiscourseAi module Summarization module Models class Base + class << self + def available_strategies + foldable_models = [ + Models::OpenAi.new("open_ai:gpt-4", max_tokens: 8192), + Models::OpenAi.new("open_ai:gpt-4-32k", max_tokens: 32_768), + Models::OpenAi.new("open_ai:gpt-4-turbo", max_tokens: 100_000), + Models::OpenAi.new("open_ai:gpt-4o", max_tokens: 100_000), + Models::OpenAi.new("open_ai:gpt-3.5-turbo", max_tokens: 4096), + Models::OpenAi.new("open_ai:gpt-3.5-turbo-16k", max_tokens: 16_384), + Models::Gemini.new("google:gemini-pro", max_tokens: 32_768), + Models::Gemini.new("google:gemini-1.5-pro", max_tokens: 800_000), + Models::Gemini.new("google:gemini-1.5-flash", max_tokens: 800_000), + ] + + claude_prov = "anthropic" + if DiscourseAi::Completions::Endpoints::AwsBedrock.correctly_configured?("claude-2") + claude_prov = "aws_bedrock" + end + + foldable_models << Models::Anthropic.new("#{claude_prov}:claude-2", max_tokens: 200_000) + foldable_models << Models::Anthropic.new( + "#{claude_prov}:claude-instant-1", + max_tokens: 100_000, + ) + foldable_models << Models::Anthropic.new( + "#{claude_prov}:claude-3-haiku", + max_tokens: 200_000, + ) + foldable_models << Models::Anthropic.new( + "#{claude_prov}:claude-3-sonnet", + max_tokens: 200_000, + ) + + foldable_models << Models::Anthropic.new( + "#{claude_prov}:claude-3-opus", + max_tokens: 200_000, + ) + + mixtral_prov = "hugging_face" + if DiscourseAi::Completions::Endpoints::Vllm.correctly_configured?( + "mistralai/Mixtral-8x7B-Instruct-v0.1", + ) + mixtral_prov = "vllm" + end + + foldable_models << Models::Mixtral.new( + "#{mixtral_prov}:mistralai/Mixtral-8x7B-Instruct-v0.1", + max_tokens: 32_000, + ) + + unless Rails.env.production? + foldable_models << Models::Fake.new("fake:fake", max_tokens: 8192) + end + + folded_models = foldable_models.map { |model| Strategies::FoldContent.new(model) } + + folded_models + end + + def find_strategy(strategy_model) + available_strategies.detect { |s| s.model == strategy_model } + end + + def selected_strategy + return if SiteSetting.ai_summarization_strategy.blank? + + find_strategy(SiteSetting.ai_summarization_strategy) + end + + def can_see_summary?(target, user) + return false if SiteSetting.ai_summarization_strategy.blank? + return false if target.class == Topic && target.private_message? + + has_cached_summary = AiSummary.exists?(target: target) + 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.ai_custom_summarization_allowed_groups_map.any? do |group_id| + user_group_ids.include?(group_id) + end + end + end + def initialize(model_name, max_tokens:) @model_name = model_name @max_tokens = max_tokens end + # Some strategies could require other conditions to work correctly, + # like site settings. + # This method gets called when admins attempt to select it, + # checking if we met those conditions. def correctly_configured? raise NotImplemented end + # Strategy name to display to admins in the available strategies dropdown. def display_name raise NotImplemented end + # If we don't meet the conditions to enable this strategy, + # we'll display this hint as an error to admins. def configuration_hint raise NotImplemented end + # The idea behind this method is "give me a collection of texts, + # and I'll handle the summarization to the best of my capabilities.". + # It's important to emphasize the "collection of texts" part, which implies + # it's not tied to any model and expects the "content" to be a hash instead. + # + # @param content { Hash } - Includes the content to summarize, plus additional + # context to help the strategy produce a better result. Keys present in the content hash: + # - resource_path (optional): Helps the strategy build links to the content in the summary (e.g. "/t/-/:topic_id/POST_NUMBER") + # - content_title (optional): Provides guidance about what the content is about. + # - contents (required): Array of hashes with content to summarize (e.g. [{ poster: "asd", id: 1, text: "This is a text" }]) + # All keys are required. + # @param &on_partial_blk { Block - Optional } - If the strategy supports it, the passed block + # will get called with partial summarized text as its generated. + # + # @param current_user { User } - User requesting the summary. + # + # @returns { Hash } - The summarized content. Example: + # { + # summary: "This is the final summary", + # } + def summarize(content, current_user) + raise NotImplemented + end + def available_tokens max_tokens - reserved_tokens end + # Returns the string we'll store in the selected strategy site setting. def model model_name.split(":").last end diff --git a/lib/summarization/models/fake.rb b/lib/summarization/models/fake.rb new file mode 100644 index 00000000..7398b649 --- /dev/null +++ b/lib/summarization/models/fake.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module DiscourseAi + module Summarization + module Models + class Fake < Base + def display_name + "fake" + end + + def correctly_configured? + true + end + + def configuration_hint + "" + end + + def model + "fake" + end + end + end + end +end diff --git a/lib/summarization/strategies/fold_content.rb b/lib/summarization/strategies/fold_content.rb index 2184e05a..38defd3a 100644 --- a/lib/summarization/strategies/fold_content.rb +++ b/lib/summarization/strategies/fold_content.rb @@ -3,7 +3,7 @@ module DiscourseAi module Summarization module Strategies - class FoldContent < ::Summarization::Base + class FoldContent < DiscourseAi::Summarization::Models::Base def initialize(completion_model) @completion_model = completion_model end @@ -21,122 +21,27 @@ module DiscourseAi llm = DiscourseAi::Completions::Llm.proxy(completion_model.model_name) - initial_chunks = - rebalance_chunks( - llm.tokenizer, - content[:contents].map { |c| { ids: [c[:id]], summary: format_content_item(c) } }, - ) + summary_content = + content[:contents].map { |c| { ids: [c[:id]], summary: format_content_item(c) } } - # Special case where we can do all the summarization in one pass. - if initial_chunks.length == 1 - { - summary: - summarize_single(llm, initial_chunks.first[:summary], user, opts, &on_partial_blk), - chunks: [], - } - else - summarize_chunks(llm, initial_chunks, user, opts, &on_partial_blk) - end + { + summary: + summarize_single(llm, summary_content.first[:summary], user, opts, &on_partial_blk), + } end private - def summarize_chunks(llm, chunks, user, opts, &on_partial_blk) - # Safely assume we always have more than one chunk. - summarized_chunks = summarize_in_chunks(llm, chunks, user, opts) - total_summaries_size = - llm.tokenizer.size(summarized_chunks.map { |s| s[:summary].to_s }.join) - - if total_summaries_size < completion_model.available_tokens - # Chunks are small enough, we can concatenate them. - { - summary: - concatenate_summaries( - llm, - summarized_chunks.map { |s| s[:summary] }, - user, - &on_partial_blk - ), - chunks: summarized_chunks, - } - else - # We have summarized chunks but we can't concatenate them yet. Split them into smaller summaries and summarize again. - rebalanced_chunks = rebalance_chunks(llm.tokenizer, summarized_chunks) - - summarize_chunks(llm, rebalanced_chunks, user, opts, &on_partial_blk) - end - end - def format_content_item(item) "(#{item[:id]} #{item[:poster]} said: #{item[:text]} " end - def rebalance_chunks(tokenizer, chunks) - section = { ids: [], summary: "" } - - chunks = - chunks.reduce([]) do |sections, chunk| - if tokenizer.can_expand_tokens?( - section[:summary], - chunk[:summary], - completion_model.available_tokens, - ) - section[:summary] += chunk[:summary] - section[:ids] = section[:ids].concat(chunk[:ids]) - else - sections << section - section = chunk - end - - sections - end - - chunks << section if section[:summary].present? - - chunks - end - def summarize_single(llm, text, user, opts, &on_partial_blk) prompt = summarization_prompt(text, opts) llm.generate(prompt, user: user, feature_name: "summarize", &on_partial_blk) end - def summarize_in_chunks(llm, chunks, user, opts) - chunks.map do |chunk| - prompt = summarization_prompt(chunk[:summary], opts) - - chunk[:summary] = llm.generate( - prompt, - user: user, - max_tokens: 300, - feature_name: "summarize", - ) - chunk - end - end - - def concatenate_summaries(llm, summaries, user, &on_partial_blk) - prompt = DiscourseAi::Completions::Prompt.new(<<~TEXT.strip) - You are a summarization bot that effectively concatenates disjoint summaries, creating a cohesive narrative. - The narrative you create is in the form of one or multiple paragraphs. - Your reply MUST BE a single concatenated summary using the summaries I'll provide to you. - I'm NOT interested in anything other than the concatenated summary, don't include additional text or comments. - You understand and generate Discourse forum Markdown. - You format the response, including links, using Markdown. - TEXT - - prompt.push(type: :user, content: <<~TEXT.strip) - THESE are the summaries, each one separated by a newline, all of them inside XML tags: - - - #{summaries.join("\n")} - - TEXT - - llm.generate(prompt, user: user, &on_partial_blk) - end - def summarization_prompt(input, opts) insts = +<<~TEXT You are an advanced summarization bot that generates concise, coherent summaries of provided text. diff --git a/plugin.rb b/plugin.rb index a03eadae..5981d0e3 100644 --- a/plugin.rb +++ b/plugin.rb @@ -15,6 +15,8 @@ enabled_site_setting :discourse_ai_enabled register_asset "stylesheets/modules/ai-helper/common/ai-helper.scss" +register_asset "stylesheets/modules/summarization/common/ai-summary.scss" + register_asset "stylesheets/modules/ai-bot/common/bot-replies.scss" register_asset "stylesheets/modules/ai-bot/common/ai-persona.scss" register_asset "stylesheets/modules/ai-bot/mobile/ai-persona.scss", :mobile diff --git a/spec/jobs/regular/stream_topic_ai_summary_spec.rb b/spec/jobs/regular/stream_topic_ai_summary_spec.rb new file mode 100644 index 00000000..5eb0c49f --- /dev/null +++ b/spec/jobs/regular/stream_topic_ai_summary_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +RSpec.describe Jobs::StreamTopicAiSummary do + subject(:job) { described_class.new } + + describe "#execute" do + fab!(:topic) { Fabricate(:topic, highest_post_number: 2) } + fab!(:post_1) { Fabricate(:post, topic: topic, post_number: 1) } + fab!(:post_2) { Fabricate(:post, topic: topic, post_number: 2) } + fab!(:user) { Fabricate(:leader) } + + before { Group.find(Group::AUTO_GROUPS[:trust_level_3]).add(user) } + + before { SiteSetting.ai_summarization_strategy = "fake" } + + def with_responses(responses) + DiscourseAi::Completions::Llm.with_prepared_responses(responses) { yield } + end + + describe "validates params" do + it "does nothing if there is no topic" do + messages = + MessageBus.track_publish("/discourse-ai/summaries/topic/#{topic.id}") do + job.execute(topic_id: nil, user_id: user.id) + end + + expect(messages).to be_empty + end + + it "does nothing if there is no user" do + messages = + MessageBus.track_publish("/discourse-ai/summaries/topic/#{topic.id}") do + job.execute(topic_id: topic.id, user_id: nil) + end + + expect(messages).to be_empty + end + + it "does nothing if the user is not allowed to see the topic" do + private_topic = Fabricate(:private_message_topic) + + messages = + MessageBus.track_publish("/discourse-ai/summaries/topic/#{private_topic.id}") do + job.execute(topic_id: private_topic.id, user_id: user.id) + end + + expect(messages).to be_empty + end + end + + it "publishes updates with a partial summary" do + with_responses(["dummy"]) do + messages = + MessageBus.track_publish("/discourse-ai/summaries/topic/#{topic.id}") do + job.execute(topic_id: topic.id, user_id: user.id) + end + + partial_summary_update = messages.first.data + expect(partial_summary_update[:done]).to eq(false) + expect(partial_summary_update.dig(:ai_topic_summary, :summarized_text)).to eq("dummy") + end + end + + it "publishes a final update to signal we're done and provide metadata" do + with_responses(["dummy"]) do + messages = + MessageBus.track_publish("/discourse-ai/summaries/topic/#{topic.id}") do + job.execute(topic_id: topic.id, user_id: user.id) + end + + final_update = messages.last.data + expect(final_update[:done]).to eq(true) + + expect(final_update.dig(:ai_topic_summary, :algorithm)).to eq("fake") + expect(final_update.dig(:ai_topic_summary, :outdated)).to eq(false) + expect(final_update.dig(:ai_topic_summary, :can_regenerate)).to eq(true) + expect(final_update.dig(:ai_topic_summary, :new_posts_since_summary)).to be_zero + end + end + end +end diff --git a/spec/lib/modules/summarization/base_spec.rb b/spec/lib/modules/summarization/base_spec.rb new file mode 100644 index 00000000..5af07692 --- /dev/null +++ b/spec/lib/modules/summarization/base_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +describe DiscourseAi::Summarization::Models::Base do + fab!(:user) + fab!(:group) + fab!(:topic) + + before do + group.add(user) + + SiteSetting.ai_summarization_strategy = "fake" + end + + describe "#can_see_summary?" do + context "when the user cannot generate a summary" do + before { SiteSetting.ai_custom_summarization_allowed_groups = "" } + + it "returns false" do + SiteSetting.ai_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 + AiSummary.create!( + target: topic, + summarized_text: "test", + original_content_sha: "123", + algorithm: "test", + ) + + expect(described_class.can_see_summary?(topic, user)).to eq(true) + end + end + + context "when the user can generate a summary" do + before { SiteSetting.ai_custom_summarization_allowed_groups = group.id } + + it "returns true if the user group is present in the ai_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 + AiSummary.create!( + target: topic, + summarized_text: "test", + original_content_sha: "123", + algorithm: "test", + ) + + expect(described_class.can_see_summary?(topic, nil)).to eq(true) + end + end + + context "when the topic is a PM" do + before { SiteSetting.ai_custom_summarization_allowed_groups = group.id } + let(:pm) { Fabricate(:private_message_topic) } + + it "returns false" do + expect(described_class.can_see_summary?(pm, user)).to eq(false) + end + end + end +end diff --git a/spec/lib/modules/summarization/strategies/fold_content_spec.rb b/spec/lib/modules/summarization/strategies/fold_content_spec.rb index 0333dd45..df7f1298 100644 --- a/spec/lib/modules/summarization/strategies/fold_content_spec.rb +++ b/spec/lib/modules/summarization/strategies/fold_content_spec.rb @@ -32,37 +32,5 @@ RSpec.describe DiscourseAi::Summarization::Strategies::FoldContent do expect(result[:summary]).to eq(single_summary) end end - - context "when the content to summarize doesn't fit in a single call" do - it "summarizes each chunk and then concatenates them" do - content[:contents] << { poster: "asd2", id: 2, text: summarize_text } - - result = - DiscourseAi::Completions::Llm.with_prepared_responses( - [single_summary, single_summary, concatenated_summary], - ) { |spy| strategy.summarize(content, user).tap { expect(spy.completions).to eq(3) } } - - expect(result[:summary]).to eq(concatenated_summary) - end - - it "keeps splitting into chunks until the content fits into a single call to create a cohesive narrative" do - content[:contents] << { poster: "asd2", id: 2, text: summarize_text } - max_length_response = "(1 asd said: This is a text " - chunk_of_chunks = "I'm smol" - - result = - DiscourseAi::Completions::Llm.with_prepared_responses( - [ - max_length_response, - max_length_response, - chunk_of_chunks, - chunk_of_chunks, - concatenated_summary, - ], - ) { |spy| strategy.summarize(content, user).tap { expect(spy.completions).to eq(5) } } - - expect(result[:summary]).to eq(concatenated_summary) - end - end end end diff --git a/spec/requests/summarization/chat_summary_controller_spec.rb b/spec/requests/summarization/chat_summary_controller_spec.rb new file mode 100644 index 00000000..cbc2b7bd --- /dev/null +++ b/spec/requests/summarization/chat_summary_controller_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +RSpec.describe DiscourseAi::Summarization::ChatSummaryController do + fab!(:current_user) { Fabricate(:user) } + fab!(:group) + + before do + group.add(current_user) + + SiteSetting.ai_summarization_strategy = "fake" + SiteSetting.ai_custom_summarization_allowed_groups = group.id + + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = group.id + sign_in(current_user) + end + + describe "#show" do + context "when the user is not allowed to join the channel" do + fab!(:channel) { Fabricate(:private_category_channel) } + + it "returns a 403" do + get "/discourse-ai/summarization/channels/#{channel.id}", params: { since: 6 } + + expect(response.status).to eq(403) + end + end + end +end diff --git a/spec/requests/summarization/summary_controller_spec.rb b/spec/requests/summarization/summary_controller_spec.rb new file mode 100644 index 00000000..1d9006c3 --- /dev/null +++ b/spec/requests/summarization/summary_controller_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +RSpec.describe DiscourseAi::Summarization::SummaryController do + describe "#summary" do + fab!(:topic) { Fabricate(:topic, highest_post_number: 2) } + fab!(:post_1) { Fabricate(:post, topic: topic, post_number: 1) } + fab!(:post_2) { Fabricate(:post, topic: topic, post_number: 2) } + + before { SiteSetting.ai_summarization_strategy = "fake" } + + context "for anons" do + it "returns a 404 if there is no cached summary" do + get "/discourse-ai/summarization/t/#{topic.id}.json" + + expect(response.status).to eq(404) + end + + it "returns a cached summary" do + section = + AiSummary.create!( + target: topic, + summarized_text: "test", + algorithm: "test", + original_content_sha: "test", + ) + + get "/discourse-ai/summarization/t/#{topic.id}.json" + + expect(response.status).to eq(200) + + summary = response.parsed_body + expect(summary.dig("ai_topic_summary", "summarized_text")).to eq(section.summarized_text) + end + end + + context "when the user is a member of an allowlisted group" do + fab!(:user) { Fabricate(:leader) } + + before do + sign_in(user) + Group.find(Group::AUTO_GROUPS[:trust_level_3]).add(user) + end + + it "returns a 404 if there is no topic" do + invalid_topic_id = 999 + + get "/discourse-ai/summarization/t/#{invalid_topic_id}.json" + + expect(response.status).to eq(404) + end + + it "returns a 403 if not allowed to see the topic" do + pm = Fabricate(:private_message_topic) + + get "/discourse-ai/summarization/t/#{pm.id}.json" + + expect(response.status).to eq(403) + end + + it "returns a summary" do + summary_text = "This is a summary" + DiscourseAi::Completions::Llm.with_prepared_responses([summary_text]) do + get "/discourse-ai/summarization/t/#{topic.id}.json" + + expect(response.status).to eq(200) + summary = response.parsed_body["ai_topic_summary"] + section = AiSummary.last + + expect(section.summarized_text).to eq(summary_text) + expect(summary["summarized_text"]).to eq(section.summarized_text) + expect(summary["algorithm"]).to eq("fake") + expect(summary["outdated"]).to eq(false) + expect(summary["can_regenerate"]).to eq(true) + expect(summary["new_posts_since_summary"]).to be_zero + end + end + + it "signals the summary is outdated" do + get "/discourse-ai/summarization/t/#{topic.id}.json" + + Fabricate(:post, topic: topic, post_number: 3) + topic.update!(highest_post_number: 3) + + get "/discourse-ai/summarization/t/#{topic.id}.json" + expect(response.status).to eq(200) + summary = response.parsed_body["ai_topic_summary"] + + expect(summary["outdated"]).to eq(true) + expect(summary["new_posts_since_summary"]).to eq(1) + end + end + + context "when the user is not a member of an allowlisted group" do + fab!(:user) + + before { sign_in(user) } + + it "return a 404 if there is no cached summary" do + get "/discourse-ai/summarization/t/#{topic.id}.json" + + expect(response.status).to eq(404) + end + + it "returns a cached summary" do + section = + AiSummary.create!( + target: topic, + summarized_text: "test", + algorithm: "test", + original_content_sha: "test", + ) + + get "/discourse-ai/summarization/t/#{topic.id}.json" + + expect(response.status).to eq(200) + + summary = response.parsed_body + expect(summary.dig("ai_topic_summary", "summarized_text")).to eq(section.summarized_text) + end + end + end +end diff --git a/spec/services/discourse_ai/topic_summarization_spec.rb b/spec/services/discourse_ai/topic_summarization_spec.rb new file mode 100644 index 00000000..5ff20741 --- /dev/null +++ b/spec/services/discourse_ai/topic_summarization_spec.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +describe DiscourseAi::TopicSummarization do + fab!(:user) { Fabricate(:admin) } + fab!(:topic) { Fabricate(:topic, highest_post_number: 2) } + fab!(:post_1) { Fabricate(:post, topic: topic, post_number: 1) } + fab!(:post_2) { Fabricate(:post, topic: topic, post_number: 2) } + + let(:model) do + DiscourseAi::Summarization::Strategies::FoldContent.new( + DiscourseAi::Summarization::Models::Fake.new("fake:fake", max_tokens: 8192), + ) + end + + shared_examples "includes only public-visible topics" do + subject { described_class.new(model) } + + it "only includes visible posts" do + topic.first_post.update!(hidden: true) + + posts = subject.summary_targets(topic) + + expect(posts.none?(&:hidden?)).to eq(true) + end + + it "doesn't include posts without users" do + topic.first_post.user.destroy! + + posts = subject.summary_targets(topic) + + expect(posts.detect { |p| p.id == topic.first_post.id }).to be_nil + end + + it "doesn't include deleted posts" do + topic.first_post.update!(user_id: nil) + + posts = subject.summary_targets(topic) + + expect(posts.detect { |p| p.id == topic.first_post.id }).to be_nil + end + end + + describe "#summary_targets" do + context "when the topic has a best replies summary" do + before { topic.has_summary = true } + + it_behaves_like "includes only public-visible topics" + end + + context "when the topic doesn't have a best replies summary" do + before { topic.has_summary = false } + + it_behaves_like "includes only public-visible topics" + end + end + + describe "#summarize" do + subject(:summarization) { described_class.new(model) } + + def assert_summary_is_cached(topic, summary_response) + cached_summary = AiSummary.find_by(target: topic) + + expect(cached_summary.content_range).to cover(*topic.posts.map(&:post_number)) + expect(cached_summary.summarized_text).to eq(summary) + expect(cached_summary.original_content_sha).to be_present + expect(cached_summary.algorithm).to eq("fake") + end + + context "when the content was summarized in a single chunk" do + let(:summary) { "This is the final summary" } + + it "caches the summary" do + DiscourseAi::Completions::Llm.with_prepared_responses([summary]) do + section = summarization.summarize(topic, user) + + expect(section.summarized_text).to eq(summary) + + assert_summary_is_cached(topic, summary) + end + end + + it "returns the cached version in subsequent calls" do + summarization.summarize(topic, user) + + cached_summary_text = "This is a cached summary" + cached_summary = + AiSummary.find_by(target: topic).update!( + summarized_text: cached_summary_text, + updated_at: 24.hours.ago, + ) + + section = summarization.summarize(topic, user) + expect(section.summarized_text).to eq(cached_summary_text) + end + + context "when the topic has embed content cached" do + it "embed content is used instead of the raw text" do + topic_embed = + Fabricate( + :topic_embed, + topic: topic, + embed_content_cache: "

hello world new post :D

", + ) + + DiscourseAi::Completions::Llm.with_prepared_responses(["A summary"]) do |spy| + summarization.summarize(topic, user) + + prompt_raw = + spy + .prompt_messages + .reduce(+"") do |memo, m| + memo << m[:content] << "\n" + + memo + end + + expect(prompt_raw).to include(topic_embed.embed_content_cache) + end + end + end + end + + describe "invalidating cached summaries" do + let(:cached_text) { "This is a cached summary" } + let(:updated_summary) { "This is the final summary" } + + def cached_summary + AiSummary.find_by(target: topic) + end + + before do + DiscourseAi::Completions::Llm.with_prepared_responses([cached_text]) do + summarization.summarize(topic, user) + end + + cached_summary.update!(summarized_text: cached_text, created_at: 24.hours.ago) + end + + context "when the user can requests new summaries" do + context "when there are no new posts" do + it "returns the cached summary" do + section = summarization.summarize(topic, user) + + expect(section.summarized_text).to eq(cached_text) + end + end + + context "when there are new posts" do + before { cached_summary.update!(original_content_sha: "outdated_sha") } + + it "returns a new summary" do + DiscourseAi::Completions::Llm.with_prepared_responses([updated_summary]) do + section = summarization.summarize(topic, user) + + expect(section.summarized_text).to eq(updated_summary) + end + end + + context "when the cached summary is less than one hour old" do + before { cached_summary.update!(created_at: 30.minutes.ago) } + + 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 + DiscourseAi::Completions::Llm.with_prepared_responses([updated_summary]) do + section = summarization.summarize(topic, user, skip_age_check: true) + + expect(section.summarized_text).to eq(updated_summary) + end + end + end + end + end + end + + describe "stream partial updates" do + let(:summary) { "This is the final summary" } + + it "receives a blk that is passed to the underlying strategy and called with partial summaries" do + partial_result = +"" + + DiscourseAi::Completions::Llm.with_prepared_responses([summary]) do + summarization.summarize(topic, user) do |partial_summary| + partial_result << partial_summary + end + end + + expect(partial_result).to eq(summary) + end + end + end +end diff --git a/spec/system/summarization/chat_summarization_spec.rb b/spec/system/summarization/chat_summarization_spec.rb new file mode 100644 index 00000000..38ee34d9 --- /dev/null +++ b/spec/system/summarization/chat_summarization_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +RSpec.describe "Summarize a channel since your last visit", type: :system do + fab!(:current_user) { Fabricate(:user) } + fab!(:group) + fab!(:channel) { Fabricate(:chat_channel) } + fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel) } + let(:chat) { PageObjects::Pages::Chat.new } + let(:summarization_result) { "This is a summary" } + + before do + group.add(current_user) + + SiteSetting.ai_summarization_strategy = "fake" + SiteSetting.ai_custom_summarization_allowed_groups = group.id.to_s + + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = group.id.to_s + sign_in(current_user) + chat_system_bootstrap(current_user, [channel]) + end + + it "displays a summary of the messages since the selected timeframe" do + DiscourseAi::Completions::Llm.with_prepared_responses([summarization_result]) do + chat.visit_channel(channel) + + find(".chat-composer-dropdown__trigger-btn").click + find(".chat-composer-dropdown__action-btn.channel-summary").click + + expect(page.has_css?(".chat-modal-channel-summary")).to eq(true) + + find(".summarization-since").click + find(".select-kit-row[data-value=\"3\"]").click + + expect(find(".summary-area").text).to eq(summarization_result) + end + end +end diff --git a/test/javascripts/acceptance/topic-summary-test.js b/test/javascripts/acceptance/topic-summary-test.js new file mode 100644 index 00000000..03ee27b7 --- /dev/null +++ b/test/javascripts/acceptance/topic-summary-test.js @@ -0,0 +1,110 @@ +import { click, visit } from "@ember/test-helpers"; +import { test } from "qunit"; +import topicFixtures from "discourse/tests/fixtures/topic"; +import { + acceptance, + publishToMessageBus, + updateCurrentUser, +} from "discourse/tests/helpers/qunit-helpers"; +import { cloneJSON } from "discourse-common/lib/object"; + +acceptance("Topic - Summary", function (needs) { + const currentUserId = 5; + + needs.user(); + needs.pretender((server, helper) => { + server.get("/t/1.json", () => { + const json = cloneJSON(topicFixtures["/t/130.json"]); + json.id = 1; + json.summarizable = true; + + return helper.response(json); + }); + + server.get("/discourse-ai/summarization/t/1", () => { + return helper.response({}); + }); + }); + + needs.hooks.beforeEach(() => { + updateCurrentUser({ id: currentUserId }); + }); + + test("displays streamed summary", async function (assert) { + await visit("/t/-/1"); + + const partialSummary = "This a"; + await publishToMessageBus("/discourse-ai/summaries/topic/1", { + done: false, + ai_topic_summary: { summarized_text: partialSummary }, + }); + + await click(".ai-topic-summarization"); + + assert + .dom(".ai-summary-box .generated-summary p") + .hasText(partialSummary, "Updates the summary with a partial result"); + + const finalSummary = "This is a completed summary"; + await publishToMessageBus("/discourse-ai/summaries/topic/1", { + done: true, + ai_topic_summary: { + summarized_text: finalSummary, + summarized_on: "2023-01-01T04:00:00.000Z", + algorithm: "OpenAI GPT-4", + outdated: false, + new_posts_since_summary: false, + can_regenerate: true, + }, + }); + + assert + .dom(".ai-summary-box .generated-summary p") + .hasText(finalSummary, "Updates the summary with a final result"); + + assert + .dom(".ai-summary-box .summarized-on") + .exists("summary metadata exists"); + }); +}); + +acceptance("Topic - Summary - Anon", function (needs) { + const finalSummary = "This is a completed summary"; + + needs.pretender((server, helper) => { + server.get("/t/1.json", () => { + const json = cloneJSON(topicFixtures["/t/280/1.json"]); + json.id = 1; + json.summarizable = true; + + return helper.response(json); + }); + + server.get("/discourse-ai/summarization/t/1", () => { + return helper.response({ + ai_topic_summary: { + summarized_text: finalSummary, + summarized_on: "2023-01-01T04:00:00.000Z", + algorithm: "OpenAI GPT-4", + outdated: false, + new_posts_since_summary: false, + can_regenerate: false, + }, + }); + }); + }); + + test("displays cached summary immediately", async function (assert) { + await visit("/t/-/1"); + + await click(".ai-topic-summarization"); + + assert + .dom(".ai-summary-box .generated-summary p") + .hasText(finalSummary, "Updates the summary with the result"); + + assert + .dom(".ai-summary-box .summarized-on") + .exists("summary metadata exists"); + }); +});