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 5 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
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
35 changes: 35 additions & 0 deletions lib/summarization/entry_point.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,41 @@ 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
# Hack(roman): Not ideal but at this point I don't have a better way of knowing if I'm serializing items for the hot filter.
# If the association wasn't loaded, assume we don't care about summaries. Including it anyways will result in multiple n+1 queries.
# In the future, the serializer could have more informacion about the topic list, so we don't depend on this.
SiteSetting.ai_summarization_enabled &&
SiteSetting.ai_summarize_max_hot_topics_gists_per_batch > 0 &&
object.ai_summaries.loaded?
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
module DiscourseAi
module Summarization
module Strategies
class TopicGist < Base
class HotTopicGists < Base
def type
AiSummary.summary_types[:gist]
end
Expand All @@ -13,20 +13,21 @@ def targets_data

op_post_number = 1

last_twenty_posts =
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")
.order("post_number DESC")
.limit(20)
.where("created_at >= ?", hot_topics_recent_cutoff)
.pluck(:post_number)

posts_data =
Post
.where(topic_id: target.id)
.joins(:user)
.where("post_number IN (?)", last_twenty_posts << op_post_number)
.where("post_number IN (?)", recent_hot_posts << op_post_number)
.order(:post_number)
.pluck(:post_number, :raw, :username)

Expand All @@ -47,6 +48,12 @@ 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)
Expand All @@ -61,25 +68,36 @@ def concatenation_prompt(texts_to_summarize)
end

def summarize_single_prompt(input, opts)
prompt = DiscourseAi::Completions::Prompt.new(<<~TEXT.strip)
You are an advanced summarization bot. Your task is to analyze a given conversation and generate a single,
concise sentence that clearly conveys the main topic and purpose of the discussion to someone with no prior context.
statements = input.split(/(?=\d+\) \w+ said:)/)

- Focus on the central theme or issue being addressed, while maintaining an objective and neutral tone.
- Avoid including extraneous details or subjective opinions.
- Maintain the original language of the text being summarized.
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)
#{opts[:content_title].present? ? "The discussion title is: " + opts[:content_title] + ".\n" : ""}
Here are the posts, inside <input></input> XML tags:
### Context:

The conversation began with the following statement:

<input>
#{input}
</input>

Generate a single sentence of the text above maintaining the original language.
#{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
Expand Down
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
121 changes: 121 additions & 0 deletions spec/jobs/regular/hot_topics_gist_batch_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# frozen_string_literal: true

RSpec.describe Jobs::HotTopicsGistBatch do
fab!(:topic_1) { Fabricate(:topic) }
fab!(:post_1) { Fabricate(:post, topic: topic_1, post_number: 1) }
fab!(:post_2) { Fabricate(:post, topic: topic_1, post_number: 2) }

before do
assign_fake_provider_to(:ai_summarization_model)
SiteSetting.ai_summarization_enabled = true
SiteSetting.ai_summarize_max_hot_topics_gists_per_batch = 100
end

describe "#execute" do
context "when there is a topic with a hot score" do
before { TopicHotScore.create!(topic_id: topic_1.id, score: 0.1) }

it "does nothing if the plugin is disabled" do
SiteSetting.discourse_ai_enabled = false

subject.execute({})

gist = AiSummary.gist.find_by(target: topic_1)
expect(gist).to be_nil
end

it "does nothing if the summarization module is disabled" do
SiteSetting.ai_summarization_enabled = false

subject.execute({})

gist = AiSummary.gist.find_by(target: topic_1)
expect(gist).to be_nil
end

it "does nothing if hot topics summarization is disabled" do
SiteSetting.ai_summarize_max_hot_topics_gists_per_batch = 0

subject.execute({})

gist = AiSummary.gist.find_by(target: topic_1)
expect(gist).to be_nil
end

it "creates a gist" do
gist_result = "I'm a gist"

DiscourseAi::Completions::Llm.with_prepared_responses([gist_result]) { subject.execute({}) }

gist = AiSummary.gist.find_by(target: topic_1)
expect(gist.summarized_text).to eq(gist_result)
end

context "when we already generated a gist of it" do
fab!(:ai_gist) do
Fabricate(
:topic_ai_gist,
target: topic_1,
original_content_sha: AiSummary.build_sha("12"),
)
end

it "does nothing if the gist is up to date" do
subject.execute({})

gist = AiSummary.gist.find_by(target: topic_1)
expect(gist.summarized_text).to eq(ai_gist.summarized_text)
expect(gist.original_content_sha).to eq(ai_gist.original_content_sha)
end

it "regenerates it if it's outdated" do
Fabricate(:post, topic: topic_1, post_number: 3)
gist_result = "They updated me"

DiscourseAi::Completions::Llm.with_prepared_responses([gist_result]) do
subject.execute({})
end

gist = AiSummary.gist.find_by(target: topic_1)
expect(gist.summarized_text).to eq(gist_result)
expect(gist.original_content_sha).to eq(AiSummary.build_sha("123"))
end
end
end

context "when there is a topic but it doesn't have a hot score" do
it "does nothing" do
subject.execute({})

gist = AiSummary.gist.find_by(target: topic_1)
expect(gist).to be_nil
end
end

context "when there are multiple hot topics" do
fab!(:topic_2) { Fabricate(:topic) }
fab!(:post_2_1) { Fabricate(:post, topic: topic_2, post_number: 1) }
fab!(:post_2_2) { Fabricate(:post, topic: topic_2, post_number: 2) }

before do
TopicHotScore.create!(topic_id: topic_1.id, score: 0.2)
TopicHotScore.create!(topic_id: topic_2.id, score: 0.4)
end

it "processes them by score order" do
topic_1_gist = "I'm gist of topic 1"
topic_2_gist = "I'm gist of topic 2"

DiscourseAi::Completions::Llm.with_prepared_responses([topic_2_gist, topic_1_gist]) do
subject.execute({})
end

gist = AiSummary.gist.find_by(target: topic_1)
expect(gist.summarized_text).to eq(topic_1_gist)

gist_2 = AiSummary.gist.find_by(target: topic_2)
expect(gist_2.summarized_text).to eq(topic_2_gist)
end
end
end
end
Loading