Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .discourse-compatibility
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
< 3.4.0.beta3-dev: ecf1bb49d737ea15308400f22f89d1d1e71d13d
< 3.4.0.beta1-dev: 9d887ad4ace8e33c3fe7dbb39237e882c08b4f0b
< 3.3.0.beta5-dev: 4d8090002f6dcd8e34d41033606bf131fa221475
< 3.3.0.beta2-dev: 61890b667c06299841ae88946f84a112f00060e1
Expand Down
24 changes: 24 additions & 0 deletions app/jobs/regular/hot_topics_gist_batch.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

module ::Jobs
class HotTopicsGistBatch < ::Jobs::Base
def execute(args)
return if !SiteSetting.discourse_ai_enabled
return if !SiteSetting.ai_summarization_enabled
return if SiteSetting.ai_summarize_max_hot_topics_gists_per_batch.zero?

Topic
.joins("JOIN topic_hot_scores on topics.id = topic_hot_scores.topic_id")
.order("topic_hot_scores.score DESC")
.limit(SiteSetting.ai_summarize_max_hot_topics_gists_per_batch)
.each do |topic|
summarizer = DiscourseAi::Summarization.topic_gist(topic)
gist = summarizer.existing_summary

summarizer.delete_cached_summaries! if gist && gist.outdated

summarizer.summarize(Discourse.system_user)
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Component from "@glimmer/component";

export default class AiTopicGist extends Component {
static shouldRender(outletArgs) {
return outletArgs?.topic?.ai_topic_gist && !outletArgs.topic.excerpt;
}

<template>
<div class="ai-topic-gist">
<div class="ai-topic-gist__text">
{{@outletArgs.topic.ai_topic_gist}}
</div>
</div>
</template>
}
Original file line number Diff line number Diff line change
Expand Up @@ -215,3 +215,11 @@
opacity: 1;
}
}

.ai-topic-gist {
margin-top: 0.5em;

&__text {
font-size: var(--font-down-2);
}
}
1 change: 1 addition & 0 deletions config/locales/server.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ en:
ai_summarization_model: "Model to use for summarization."
ai_custom_summarization_allowed_groups: "Groups allowed to use create new summaries."
ai_pm_summarization_allowed_groups: "Groups allowed to create and view summaries in PMs."
ai_summarize_max_hot_topics_gists_per_batch: "After updating topics in the hot list, we'll generate brief summaries of the first N ones. (Disabled when 0)"

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"
Expand Down
4 changes: 4 additions & 0 deletions config/settings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,10 @@ discourse_ai:
type: group_list
list_type: compact
default: "3|13" # 3: @staff, 13: @trust_level_3
ai_summarize_max_hot_topics_gists_per_batch:
default: 0
min: 0
max: 1000
ai_summarization_strategy: # TODO(roman): Deprecated. Remove by Sept 2024
type: enum
default: ""
Expand Down
2 changes: 1 addition & 1 deletion lib/summarization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def self.topic_gist(topic)
if SiteSetting.ai_summarization_model.present? && SiteSetting.ai_summarization_enabled
DiscourseAi::Summarization::FoldContent.new(
DiscourseAi::Completions::Llm.proxy(SiteSetting.ai_summarization_model),
DiscourseAi::Summarization::Strategies::TopicGist.new(topic),
DiscourseAi::Summarization::Strategies::HotTopicGists.new(topic),
)
else
nil
Expand Down
32 changes: 32 additions & 0 deletions lib/summarization/entry_point.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,38 @@ def inject_into(plugin)
plugin.add_to_serializer(:web_hook_topic_view, :summarizable) do
scope.can_see_summary?(object.topic, AiSummary.summary_types[:complete])
end

plugin.register_modifier(:topic_query_create_list_topics) do |topics, options|
if options[:filter] == :hot && SiteSetting.ai_summarization_enabled &&
SiteSetting.ai_summarize_max_hot_topics_gists_per_batch > 0
topics.includes(:ai_summaries).where(
"ai_summaries.id IS NULL OR ai_summaries.summary_type = ?",
AiSummary.summary_types[:gist],
)
else
topics
end
end

plugin.add_to_serializer(
:topic_list_item,
:ai_topic_gist,
include_condition: -> do
SiteSetting.ai_summarization_enabled &&
SiteSetting.ai_summarize_max_hot_topics_gists_per_batch > 0 &&
options[:filter] == :hot
end,
) do
summaries = object.ai_summaries.to_a

# Summaries should always have one or zero elements here.
# This is an extra safeguard to avoid including regular summaries.
summaries.find { |s| s.summary_type == "gist" }&.summarized_text
end

# To make sure hot topic gists are inmediately up to date, we rely on this event
# instead of using a scheduled job.
plugin.on(:topic_hot_scores_updated) { Jobs.enqueue(:hot_topics_gist_batch) }
end
end
end
Expand Down
121 changes: 121 additions & 0 deletions lib/summarization/strategies/hot_topic_gists.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# frozen_string_literal: true

module DiscourseAi
module Summarization
module Strategies
class HotTopicGists < Base
def type
AiSummary.summary_types[:gist]
end

def targets_data
content = { content_title: target.title, contents: [] }

op_post_number = 1

hot_topics_recent_cutoff = Time.zone.now - SiteSetting.hot_topics_recent_days.days

recent_hot_posts =
Post
.where(topic_id: target.id)
.where("post_type = ?", Post.types[:regular])
.where("NOT hidden")
.where("created_at >= ?", hot_topics_recent_cutoff)
.pluck(:post_number)

# It may happen that a topic is hot without any recent posts
# In that case, we'll just grab the last 20 posts
# for an useful summary of the current state of the topic
if recent_hot_posts.empty?
recent_hot_posts =
Post
.where(topic_id: target.id)
.where("post_type = ?", Post.types[:regular])
.where("NOT hidden")
.order("post_number DESC")
.limit(20)
.pluck(:post_number)
end
posts_data =
Post
.where(topic_id: target.id)
.joins(:user)
.where("post_number IN (?)", recent_hot_posts << op_post_number)
.order(:post_number)
.pluck(:post_number, :raw, :username)

posts_data.each do |(pn, raw, username)|
raw_text = raw

if pn == 1 && target.topic_embed&.embed_content_cache.present?
raw_text = target.topic_embed&.embed_content_cache
end

content[:contents] << { poster: username, id: pn, text: raw_text }
end

content
end

def concatenation_prompt(texts_to_summarize)
prompt = DiscourseAi::Completions::Prompt.new(<<~TEXT.strip)
You are a summarization bot tasked with creating a single, concise sentence by merging disjointed summaries into a cohesive statement.
Your response should strictly be this single, comprehensive sentence, without any additional text or comments.

- Focus on the central theme or issue being addressed, maintaining an objective and neutral tone.
- Exclude extraneous details or subjective opinions.
- Use the original language of the text.
- Begin directly with the main topic or issue, avoiding introductory phrases.
- Limit the summary to a maximum of 20 words.
TEXT

prompt.push(type: :user, content: <<~TEXT.strip)
THESE are the summaries, each one separated by a newline, all of them inside <input></input> XML tags:

<input>
#{texts_to_summarize.join("\n")}
</input>
TEXT

prompt
end

def summarize_single_prompt(input, opts)
statements = input.split(/(?=\d+\) \w+ said:)/)

prompt = DiscourseAi::Completions::Prompt.new(<<~TEXT.strip)
You are an advanced summarization bot. Analyze a given conversation and produce a concise,
single-sentence summary that conveys the main topic and current developments to someone with no prior context.

### Guidelines:

- Emphasize the most recent updates while considering their significance within the original post.
- Focus on the central theme or issue being addressed, maintaining an objective and neutral tone.
- Exclude extraneous details or subjective opinions.
- Use the original language of the text.
- Begin directly with the main topic or issue, avoiding introductory phrases.
- Limit the summary to a maximum of 20 words.
TEXT

prompt.push(type: :user, content: <<~TEXT.strip)
### Context:

The conversation began with the following statement:

#{opts[:content_title].present? ? "The discussion title is: " + opts[:content_title] + ".\n" : ""}

#{statements&.pop}

Subsequent discussion includes the following:

#{statements&.join}

Your task is to focus on these latest messages, capturing their meaning in the context of the initial post.
TEXT

prompt
end
end
end
end
end
90 changes: 0 additions & 90 deletions lib/summarization/strategies/topic_gist.rb

This file was deleted.

9 changes: 9 additions & 0 deletions lib/topic_extensions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

module DiscourseAi
module TopicExtensions
extend ActiveSupport::Concern

prepended { has_many :ai_summaries, as: :target }
end
end
5 changes: 4 additions & 1 deletion plugin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ module ::DiscourseAi
require_relative "spec/support/stable_diffusion_stubs"
end

reloadable_patch { |plugin| Guardian.prepend DiscourseAi::GuardianExtensions }
reloadable_patch do |plugin|
Guardian.prepend DiscourseAi::GuardianExtensions
Topic.prepend DiscourseAi::TopicExtensions
end

register_modifier(:post_should_secure_uploads?) do |_, _, topic|
if topic.private_message? && SharedAiConversation.exists?(target: topic)
Expand Down
14 changes: 14 additions & 0 deletions spec/fabricators/ai_summary_fabricator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

Fabricator(:ai_summary) do
summarized_text "complete summary"
original_content_sha "123"
algorithm "test"
target { Fabricate(:topic) }
summary_type AiSummary.summary_types[:complete]
end

Fabricator(:topic_ai_gist, from: :ai_summary) do
summarized_text "gist"
summary_type AiSummary.summary_types[:gist]
end
Loading