diff --git a/app/controllers/discourse_ai/ai_bot/bot_controller.rb b/app/controllers/discourse_ai/ai_bot/bot_controller.rb
index f405dd65..bafcec36 100644
--- a/app/controllers/discourse_ai/ai_bot/bot_controller.rb
+++ b/app/controllers/discourse_ai/ai_bot/bot_controller.rb
@@ -47,9 +47,9 @@ module DiscourseAi
def discover
ai_persona =
- AiPersona.all_personas.find do |persona|
- persona.id == SiteSetting.ai_bot_discover_persona.to_i
- end
+ AiPersona
+ .all_personas(enabled_only: false)
+ .find { |persona| persona.id == SiteSetting.ai_bot_discover_persona.to_i }
if ai_persona.nil? || !current_user.in_any_groups?(ai_persona.allowed_group_ids.to_a)
raise Discourse::InvalidAccess.new
diff --git a/app/jobs/regular/stream_discover_reply.rb b/app/jobs/regular/stream_discover_reply.rb
index f8b6d256..3e6e9594 100644
--- a/app/jobs/regular/stream_discover_reply.rb
+++ b/app/jobs/regular/stream_discover_reply.rb
@@ -9,9 +9,9 @@ module Jobs
return if (query = args[:query]).blank?
ai_persona_klass =
- AiPersona.all_personas.find do |persona|
- persona.id == SiteSetting.ai_bot_discover_persona.to_i
- end
+ AiPersona
+ .all_personas(enabled_only: false)
+ .find { |persona| persona.id == SiteSetting.ai_bot_discover_persona.to_i }
if ai_persona_klass.nil? || !user.in_any_groups?(ai_persona_klass.allowed_group_ids.to_a)
return
diff --git a/app/models/ai_persona.rb b/app/models/ai_persona.rb
index 9c1399ef..d3e149f5 100644
--- a/app/models/ai_persona.rb
+++ b/app/models/ai_persona.rb
@@ -46,13 +46,18 @@ class AiPersona < ActiveRecord::Base
scope :ordered, -> { order("priority DESC, lower(name) ASC") }
- def self.all_personas
+ def self.all_personas(enabled_only: true)
persona_cache[:value] ||= AiPersona
.ordered
- .where(enabled: true)
.all
.limit(MAX_PERSONAS_PER_SITE)
.map(&:class_instance)
+
+ if enabled_only
+ persona_cache[:value].select { |p| p.enabled }
+ else
+ persona_cache[:value]
+ end
end
def self.persona_users(user: nil)
@@ -176,6 +181,7 @@ class AiPersona < ActiveRecord::Base
description
allowed_group_ids
tool_details
+ enabled
]
instance_attributes = {}
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 574fb7f7..2c32001c 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -303,6 +303,12 @@ en:
web_artifact_creator:
name: "Web Artifact Creator"
description: "AI Bot specialized in creating interactive web artifacts"
+ summarizer:
+ name: "Summarizer"
+ description: "Default persona used to power AI summaries"
+ short_summarizer:
+ name: "Summarizer (short form)"
+ description: "Default persona used to power AI short summaries for topic lists' items"
topic_not_found: "Summary unavailable, topic not found!"
summarizing: "Summarizing topic"
searching: "Searching for: '%{query}'"
@@ -452,6 +458,7 @@ en:
llm:
configuration:
+ create_llm: "You need to setup an LLM before enabling this feature"
disable_module_first: "You have to disable %{setting} first."
set_llm_first: "Set %{setting} first"
model_unreachable: "We couldn't get a response from this model. Check your settings first."
diff --git a/config/settings.yml b/config/settings.yml
index 5801d634..b377d89b 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -240,18 +240,30 @@ discourse_ai:
type: enum
enum: "DiscourseAi::Configuration::LlmEnumerator"
validator: "DiscourseAi::Configuration::LlmValidator"
+ hidden: true
+ ai_summarization_persona:
+ default: "-11"
+ type: enum
+ enum: "DiscourseAi::Configuration::PersonaEnumerator"
+
ai_pm_summarization_allowed_groups:
type: group_list
list_type: compact
default: ""
- ai_custom_summarization_allowed_groups:
+ ai_custom_summarization_allowed_groups: # Deprecated. TODO(roman): Remove 2025-09-01
type: group_list
list_type: compact
default: "3|13" # 3: @staff, 13: @trust_level_3
+ hidden: true
ai_summary_gists_enabled:
default: false
hidden: true
- ai_summary_gists_allowed_groups:
+ ai_summary_gists_persona:
+ default: "-12"
+ type: enum
+ enum: "DiscourseAi::Configuration::PersonaEnumerator"
+ hidden: true
+ ai_summary_gists_allowed_groups: # Deprecated. TODO(roman): Remove 2025-09-01
type: group_list
list_type: compact
default: "0" #everyone
diff --git a/db/fixtures/personas/603_ai_personas.rb b/db/fixtures/personas/603_ai_personas.rb
index eaea14ab..fe09faca 100644
--- a/db/fixtures/personas/603_ai_personas.rb
+++ b/db/fixtures/personas/603_ai_personas.rb
@@ -1,17 +1,39 @@
# frozen_string_literal: true
+summarization_personas = [DiscourseAi::Personas::Summarizer, DiscourseAi::Personas::ShortSummarizer]
+
+def from_setting(setting_name)
+ DB.query_single(
+ "SELECT value FROM site_settings WHERE name = :setting_name",
+ setting_name: setting_name,
+ )
+end
+
DiscourseAi::Personas::Persona.system_personas.each do |persona_class, id|
persona = AiPersona.find_by(id: id)
if !persona
persona = AiPersona.new
persona.id = id
+
if persona_class == DiscourseAi::Personas::WebArtifactCreator
# this is somewhat sensitive, so we default it to staff
persona.allowed_group_ids = [Group::AUTO_GROUPS[:staff]]
+ elsif summarization_personas.include?(persona_class)
+ # Copy group permissions from site settings.
+ default_groups = [Group::AUTO_GROUPS[:staff], Group::AUTO_GROUPS[:trust_level_3]]
+
+ setting_name = "ai_custom_summarization_allowed_groups"
+ if persona_class == DiscourseAi::Personas::ShortSummarizer
+ setting_name = "ai_summary_gists_allowed_groups"
+ default_groups = [] # Blank == everyone
+ end
+
+ persona.allowed_group_ids = from_setting(setting_name).first&.split("|") || default_groups
else
persona.allowed_group_ids = [Group::AUTO_GROUPS[:trust_level_0]]
end
- persona.enabled = true
+
+ persona.enabled = !summarization_personas.include?(persona_class)
persona.priority = true if persona_class == DiscourseAi::Personas::General
end
@@ -22,16 +44,16 @@ DiscourseAi::Personas::Persona.system_personas.each do |persona_class, id|
persona_class.name + SecureRandom.hex,
]
persona.name = DB.query_single(<<~SQL, names, id).first
- SELECT guess_name
- FROM (
- SELECT unnest(Array[?]) AS guess_name
- FROM (SELECT 1) as t
- ) x
- LEFT JOIN ai_personas ON ai_personas.name = x.guess_name AND ai_personas.id <> ?
- WHERE ai_personas.id IS NULL
- ORDER BY x.guess_name ASC
- LIMIT 1
- SQL
+ SELECT guess_name
+ FROM (
+ SELECT unnest(Array[?]) AS guess_name
+ FROM (SELECT 1) as t
+ ) x
+ LEFT JOIN ai_personas ON ai_personas.name = x.guess_name AND ai_personas.id <> ?
+ WHERE ai_personas.id IS NULL
+ ORDER BY x.guess_name ASC
+ LIMIT 1
+ SQL
persona.description = persona_class.description
diff --git a/lib/configuration/llm_dependency_validator.rb b/lib/configuration/llm_dependency_validator.rb
index 926caec6..cfdb0c80 100644
--- a/lib/configuration/llm_dependency_validator.rb
+++ b/lib/configuration/llm_dependency_validator.rb
@@ -10,17 +10,27 @@ module DiscourseAi
def valid_value?(val)
return true if val == "f"
- @llm_dependency_setting_name =
- DiscourseAi::Configuration::LlmValidator.new.choose_llm_setting_for(@opts[:name])
+ if @opts[:name] == :ai_summarization_enabled
+ has_llms = LlmModel.count > 0
+ @no_llms_configured = !has_llms
+ has_llms
+ else
+ @llm_dependency_setting_name =
+ DiscourseAi::Configuration::LlmValidator.new.choose_llm_setting_for(@opts[:name])
- SiteSetting.public_send(@llm_dependency_setting_name).present?
+ SiteSetting.public_send(@llm_dependency_setting_name).present?
+ end
end
def error_message
- I18n.t(
- "discourse_ai.llm.configuration.set_llm_first",
- setting: @llm_dependency_setting_name,
- )
+ if @llm_dependency_setting_name
+ I18n.t(
+ "discourse_ai.llm.configuration.set_llm_first",
+ setting: @llm_dependency_setting_name,
+ )
+ elsif @no_llms_configured
+ I18n.t("discourse_ai.llm.configuration.create_llm")
+ end
end
end
end
diff --git a/lib/configuration/llm_enumerator.rb b/lib/configuration/llm_enumerator.rb
index f7de3e7c..a68a8098 100644
--- a/lib/configuration/llm_enumerator.rb
+++ b/lib/configuration/llm_enumerator.rb
@@ -26,7 +26,9 @@ module DiscourseAi
end
if SiteSetting.ai_summarization_enabled
- model_id = SiteSetting.ai_summarization_model.split(":").last.to_i
+ summarization_persona = AiPersona.find_by(id: SiteSetting.ai_summarization_persona)
+ model_id = summarization_persona.default_llm_id || LlmModel.last&.id
+
rval[model_id] << { type: :ai_summarization }
end
diff --git a/lib/configuration/persona_enumerator.rb b/lib/configuration/persona_enumerator.rb
index c44dd77d..c115bc50 100644
--- a/lib/configuration/persona_enumerator.rb
+++ b/lib/configuration/persona_enumerator.rb
@@ -10,7 +10,9 @@ module DiscourseAi
end
def self.values
- AiPersona.all_personas.map { |persona| { name: persona.name, value: persona.id } }
+ AiPersona
+ .all_personas(enabled_only: false)
+ .map { |persona| { name: persona.name, value: persona.id } }
end
end
end
diff --git a/lib/discord/bot/persona_replier.rb b/lib/discord/bot/persona_replier.rb
index 425451bb..51e03475 100644
--- a/lib/discord/bot/persona_replier.rb
+++ b/lib/discord/bot/persona_replier.rb
@@ -6,7 +6,7 @@ module DiscourseAi
def initialize(body)
@persona =
AiPersona
- .all_personas
+ .all_personas(enabled_only: false)
.find { |persona| persona.id == SiteSetting.ai_discord_search_persona.to_i }
.new
@bot =
diff --git a/lib/guardian_extensions.rb b/lib/guardian_extensions.rb
index 0badfa4d..f686b50e 100644
--- a/lib/guardian_extensions.rb
+++ b/lib/guardian_extensions.rb
@@ -24,24 +24,26 @@ module DiscourseAi
def can_see_gists?
return false if !SiteSetting.ai_summarization_enabled
return false if !SiteSetting.ai_summary_gists_enabled
- if SiteSetting.ai_summary_gists_allowed_groups.to_s == Group::AUTO_GROUPS[:everyone].to_s
- return true
- end
- return false if anonymous?
- return false if SiteSetting.ai_summary_gists_allowed_groups_map.empty?
- SiteSetting.ai_summary_gists_allowed_groups_map.any? do |group_id|
- user.group_ids.include?(group_id)
+ if (ai_persona = AiPersona.find_by(id: SiteSetting.ai_summary_gists_persona)).blank?
+ return false
end
+ persona_groups = ai_persona.allowed_group_ids.to_a
+ return true if persona_groups.empty?
+ return false if anonymous?
+
+ ai_persona.allowed_group_ids.to_a.any? { |group_id| user.group_ids.include?(group_id) }
end
def can_request_summary?
return false if anonymous?
user_group_ids = user.group_ids
- SiteSetting.ai_custom_summarization_allowed_groups_map.any? do |group_id|
- user_group_ids.include?(group_id)
+ if (ai_persona = AiPersona.find_by(id: SiteSetting.ai_summarization_persona)).blank?
+ return false
end
+
+ ai_persona.allowed_group_ids.to_a.any? { |group_id| user.group_ids.include?(group_id) }
end
def can_debug_ai_bot_conversation?(target)
diff --git a/lib/personas/bot.rb b/lib/personas/bot.rb
index 62797f72..aa701abc 100644
--- a/lib/personas/bot.rb
+++ b/lib/personas/bot.rb
@@ -28,7 +28,7 @@ module DiscourseAi
attr_accessor :persona
def llm
- @llm ||= DiscourseAi::Completions::Llm.proxy(model)
+ DiscourseAi::Completions::Llm.proxy(model)
end
def force_tool_if_needed(prompt, context)
@@ -51,12 +51,12 @@ module DiscourseAi
end
end
- def reply(context, &update_blk)
+ def reply(context, llm_args: {}, &update_blk)
unless context.is_a?(BotContext)
raise ArgumentError, "context must be an instance of BotContext"
end
- llm = DiscourseAi::Completions::Llm.proxy(model)
- prompt = persona.craft_prompt(context, llm: llm)
+ current_llm = llm
+ prompt = persona.craft_prompt(context, llm: current_llm)
total_completions = 0
ongoing_chain = true
@@ -67,6 +67,7 @@ module DiscourseAi
llm_kwargs = { user: user }
llm_kwargs[:temperature] = persona.temperature if persona.temperature
llm_kwargs[:top_p] = persona.top_p if persona.top_p
+ llm_kwargs[:max_tokens] = llm_args[:max_tokens] if llm_args[:max_tokens].present?
needs_newlines = false
tools_ran = 0
@@ -82,9 +83,9 @@ module DiscourseAi
current_thinking = []
result =
- llm.generate(
+ current_llm.generate(
prompt,
- feature_name: "bot",
+ feature_name: context.feature_name,
partial_tool_calls: allow_partial_tool_calls,
output_thinking: true,
**llm_kwargs,
@@ -93,7 +94,7 @@ module DiscourseAi
persona.find_tool(
partial,
bot_user: user,
- llm: llm,
+ llm: current_llm,
context: context,
existing_tools: existing_tools,
)
@@ -120,7 +121,7 @@ module DiscourseAi
process_tool(
tool: tool,
raw_context: raw_context,
- llm: llm,
+ current_llm: current_llm,
cancel: cancel,
update_blk: update_blk,
prompt: prompt,
@@ -204,7 +205,7 @@ module DiscourseAi
def process_tool(
tool:,
raw_context:,
- llm:,
+ current_llm:,
cancel:,
update_blk:,
prompt:,
@@ -212,7 +213,7 @@ module DiscourseAi
current_thinking:
)
tool_call_id = tool.tool_call_id
- invocation_result_json = invoke_tool(tool, llm, cancel, context, &update_blk).to_json
+ invocation_result_json = invoke_tool(tool, cancel, context, &update_blk).to_json
tool_call_message = {
type: :tool_call,
@@ -246,7 +247,7 @@ module DiscourseAi
raw_context << [invocation_result_json, tool_call_id, "tool", tool.name]
end
- def invoke_tool(tool, llm, cancel, context, &update_blk)
+ def invoke_tool(tool, cancel, context, &update_blk)
show_placeholder = !context.skip_tool_details && !tool.class.allow_partial_tool_calls?
update_blk.call("", cancel, build_placeholder(tool.summary, "")) if show_placeholder
diff --git a/lib/personas/bot_context.rb b/lib/personas/bot_context.rb
index 5ac5b05c..24c042ac 100644
--- a/lib/personas/bot_context.rb
+++ b/lib/personas/bot_context.rb
@@ -14,7 +14,9 @@ module DiscourseAi
:chosen_tools,
:message_id,
:channel_id,
- :context_post_ids
+ :context_post_ids,
+ :feature_name,
+ :resource_url
def initialize(
post: nil,
@@ -29,7 +31,9 @@ module DiscourseAi
time: nil,
message_id: nil,
channel_id: nil,
- context_post_ids: nil
+ context_post_ids: nil,
+ feature_name: "bot",
+ resource_url: nil
)
@participants = participants
@user = user
@@ -45,6 +49,7 @@ module DiscourseAi
@site_title = site_title
@site_description = site_description
@time = time
+ @feature_name = feature_name
if post
@post_id = post.id
@@ -56,7 +61,7 @@ module DiscourseAi
end
# these are strings that can be safely interpolated into templates
- TEMPLATE_PARAMS = %w[time site_url site_title site_description participants]
+ TEMPLATE_PARAMS = %w[time site_url site_title site_description participants resource_url]
def lookup_template_param(key)
public_send(key.to_sym) if TEMPLATE_PARAMS.include?(key)
@@ -100,6 +105,8 @@ module DiscourseAi
site_title: @site_title,
site_description: @site_description,
skip_tool_details: @skip_tool_details,
+ feature_name: @feature_name,
+ resource_url: @resource_url,
}
end
end
diff --git a/lib/personas/persona.rb b/lib/personas/persona.rb
index fb3eb155..f614f8da 100644
--- a/lib/personas/persona.rb
+++ b/lib/personas/persona.rb
@@ -44,6 +44,8 @@ module DiscourseAi
DiscourseHelper => -8,
GithubHelper => -9,
WebArtifactCreator => -10,
+ Summarizer => -11,
+ ShortSummarizer => -12,
}
end
diff --git a/lib/personas/short_summarizer.rb b/lib/personas/short_summarizer.rb
new file mode 100644
index 00000000..d460d6c0
--- /dev/null
+++ b/lib/personas/short_summarizer.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module DiscourseAi
+ module Personas
+ class ShortSummarizer < Persona
+ def system_prompt
+ <<~PROMPT.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 40 words.
+ - Do *NOT* repeat the discussion title in the summary.
+
+ Return the summary inside tags.
+ PROMPT
+ end
+ end
+ end
+end
diff --git a/lib/personas/summarizer.rb b/lib/personas/summarizer.rb
new file mode 100644
index 00000000..a2f81463
--- /dev/null
+++ b/lib/personas/summarizer.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module DiscourseAi
+ module Personas
+ class Summarizer < Persona
+ def system_prompt
+ <<~PROMPT.strip
+ You are an advanced summarization bot that generates concise, coherent summaries of provided text.
+ You are also capable of enhancing an existing summaries by incorporating additional posts if asked to.
+
+ - Only include the summary, without any additional commentary.
+ - You understand and generate Discourse forum Markdown; including links, _italics_, **bold**.
+ - Maintain the original language of the text being summarized.
+ - Aim for summaries to be 400 words or less.
+ - Each post is formatted as ") "
+ - Cite specific noteworthy posts using the format [DESCRIPTION]({resource_url}/POST_NUMBER)
+ - Example: links to the 3rd and 6th posts by sam: sam ([#3]({resource_url}/3), [#6]({resource_url}/6))
+ - Example: link to the 6th post by jane: [agreed with]({resource_url}/6)
+ - Example: link to the 13th post by joe: [joe]({resource_url}/13)
+ - When formatting usernames either use @USERNAME OR [USERNAME]({resource_url}/POST_NUMBER)
+ PROMPT
+ end
+ end
+ end
+end
diff --git a/lib/summarization.rb b/lib/summarization.rb
index fe879491..a7b69763 100644
--- a/lib/summarization.rb
+++ b/lib/summarization.rb
@@ -2,37 +2,78 @@
module DiscourseAi
module Summarization
- def self.topic_summary(topic)
- if SiteSetting.ai_summarization_model.present? && SiteSetting.ai_summarization_enabled
+ class << self
+ def topic_summary(topic)
+ return nil if !SiteSetting.ai_summarization_enabled
+ if (ai_persona = AiPersona.find_by(id: SiteSetting.ai_summarization_persona)).blank?
+ return nil
+ end
+
+ persona_klass = ai_persona.class_instance
+ llm_model = find_summarization_model(persona_klass)
+ return nil if llm_model.blank?
+
DiscourseAi::Summarization::FoldContent.new(
- DiscourseAi::Completions::Llm.proxy(SiteSetting.ai_summarization_model),
+ build_bot(persona_klass, llm_model),
DiscourseAi::Summarization::Strategies::TopicSummary.new(topic),
)
- else
- nil
end
- end
- def self.topic_gist(topic)
- if SiteSetting.ai_summarization_model.present? && SiteSetting.ai_summarization_enabled
+ def topic_gist(topic)
+ return nil if !SiteSetting.ai_summarization_enabled
+ if (ai_persona = AiPersona.find_by(id: SiteSetting.ai_summary_gists_persona)).blank?
+ return nil
+ end
+
+ persona_klass = ai_persona.class_instance
+ llm_model = find_summarization_model(persona_klass)
+ return nil if llm_model.blank?
+
DiscourseAi::Summarization::FoldContent.new(
- DiscourseAi::Completions::Llm.proxy(SiteSetting.ai_summarization_model),
+ build_bot(persona_klass, llm_model),
DiscourseAi::Summarization::Strategies::HotTopicGists.new(topic),
)
- else
- nil
end
- end
- def self.chat_channel_summary(channel, time_window_in_hours)
- if SiteSetting.ai_summarization_model.present? && SiteSetting.ai_summarization_enabled
+ def chat_channel_summary(channel, time_window_in_hours)
+ return nil if !SiteSetting.ai_summarization_enabled
+ if (ai_persona = AiPersona.find_by(id: SiteSetting.ai_summarization_persona)).blank?
+ return nil
+ end
+
+ persona_klass = ai_persona.class_instance
+ llm_model = find_summarization_model(persona_klass)
+ return nil if llm_model.blank?
+
DiscourseAi::Summarization::FoldContent.new(
- DiscourseAi::Completions::Llm.proxy(SiteSetting.ai_summarization_model),
+ build_bot(persona_klass, llm_model),
DiscourseAi::Summarization::Strategies::ChatMessages.new(channel, time_window_in_hours),
persist_summaries: false,
)
- else
- nil
+ end
+
+ # Priorities are:
+ # 1. Persona's default LLM
+ # 2. Hidden `ai_summarization_model` setting
+ # 3. Newest LLM config
+ def find_summarization_model(persona_klass)
+ model_id =
+ persona_klass.default_llm_id || SiteSetting.ai_summarization_model&.split(":")&.last # Remove legacy custom provider.
+
+ if model_id.present?
+ LlmModel.find_by(id: model_id)
+ else
+ LlmModel.last
+ end
+ end
+
+ ### Private
+
+ def build_bot(persona_klass, llm_model)
+ persona = persona_klass.new
+ user = User.find_by(id: persona_klass.user_id) || Discourse.system_user
+
+ bot = DiscourseAi::Personas::Bot.as(user, persona: persona, model: llm_model)
end
end
end
diff --git a/lib/summarization/entry_point.rb b/lib/summarization/entry_point.rb
index ba876356..ca9cd6d5 100644
--- a/lib/summarization/entry_point.rb
+++ b/lib/summarization/entry_point.rb
@@ -6,7 +6,11 @@ module DiscourseAi
def inject_into(plugin)
plugin.add_to_serializer(:current_user, :can_summarize) do
return false if !SiteSetting.ai_summarization_enabled
- scope.user.in_any_groups?(SiteSetting.ai_custom_summarization_allowed_groups_map)
+
+ if (ai_persona = AiPersona.find_by(id: SiteSetting.ai_summarization_persona)).blank?
+ return false
+ end
+ scope.user.in_any_groups?(ai_persona.allowed_group_ids.to_a)
end
plugin.add_to_serializer(:topic_view, :summarizable) do
diff --git a/lib/summarization/fold_content.rb b/lib/summarization/fold_content.rb
index 7cacc8c0..6fcd1876 100644
--- a/lib/summarization/fold_content.rb
+++ b/lib/summarization/fold_content.rb
@@ -9,13 +9,13 @@ module DiscourseAi
# into a final version.
#
class FoldContent
- def initialize(llm, strategy, persist_summaries: true)
- @llm = llm
+ def initialize(bot, strategy, persist_summaries: true)
+ @bot = bot
@strategy = strategy
@persist_summaries = persist_summaries
end
- attr_reader :llm, :strategy
+ attr_reader :bot, :strategy
# @param user { User } - User object used for auditing usage.
# @param &on_partial_blk { Block - Optional } - The passed block will get called with the LLM partial response alongside a cancel function.
@@ -25,15 +25,11 @@ module DiscourseAi
#
# @returns { AiSummary } - Resulting summary.
def summarize(user, &on_partial_blk)
- base_summary = ""
- initial_pos = 0
-
truncated_content = content_to_summarize.map { |cts| truncate(cts) }
- folded_summary = fold(truncated_content, base_summary, initial_pos, user, &on_partial_blk)
+ summary = fold(truncated_content, user, &on_partial_blk)
- clean_summary =
- Nokogiri::HTML5.fragment(folded_summary).css("ai")&.first&.text || folded_summary
+ clean_summary = Nokogiri::HTML5.fragment(summary).css("ai")&.first&.text || summary
if persist_summaries
AiSummary.store!(
@@ -76,7 +72,7 @@ module DiscourseAi
attr_reader :persist_summaries
def llm_model
- llm.llm_model
+ bot.llm.llm_model
end
def content_to_summarize
@@ -88,52 +84,51 @@ module DiscourseAi
end
# @param items { Array } - Content to summarize. Structure will be: { poster: who wrote the content, id: a way to order content, text: content }
- # @param summary { String } - Intermediate summaries that we'll keep extending as part of our "folding" algorithm.
- # @param cursor { Integer } - Idx to know how much we already summarized.
# @param user { User } - User object used for auditing usage.
# @param &on_partial_blk { Block - Optional } - The passed block will get called with the LLM partial response alongside a cancel function.
# Note: The block is only called with results of the final summary, not intermediate summaries.
#
# The summarization algorithm.
- # The idea is to build an initial summary packing as much content as we can. Once we have the initial summary, we'll keep extending using the leftover
- # content until there is nothing left.
+ # It will summarize as much content summarize given the model's context window. If will prioriotize newer content in case it doesn't fit.
#
# @returns { String } - Resulting summary.
- def fold(items, summary, cursor, user, &on_partial_blk)
+ def fold(items, user, &on_partial_blk)
tokenizer = llm_model.tokenizer_class
- tokens_left = available_tokens - tokenizer.size(summary)
- iteration_content = []
+ tokens_left = available_tokens
+ content_in_window = []
items.each_with_index do |item, idx|
- next if idx < cursor
-
as_text = "(#{item[:id]} #{item[:poster]} said: #{item[:text]} "
if tokenizer.below_limit?(as_text, tokens_left)
- iteration_content << item
+ content_in_window << item
tokens_left -= tokenizer.size(as_text)
- cursor += 1
else
break
end
end
- prompt =
- (
- if summary.blank?
- strategy.first_summary_prompt(iteration_content)
- else
- strategy.summary_extension_prompt(summary, iteration_content)
- end
+ context =
+ DiscourseAi::Personas::BotContext.new(
+ user: user,
+ skip_tool_details: true,
+ feature_name: strategy.feature,
+ resource_url: "#{Discourse.base_path}/t/-/#{strategy.target.id}",
+ messages: strategy.as_llm_messages(content_in_window),
)
- if cursor == items.length
- llm.generate(prompt, user: user, feature_name: strategy.feature, &on_partial_blk)
- else
- latest_summary =
- llm.generate(prompt, user: user, max_tokens: 600, feature_name: strategy.feature)
- fold(items, latest_summary, cursor, user, &on_partial_blk)
- end
+ summary = +""
+ buffer_blk =
+ Proc.new do |partial, cancel, placeholder, type|
+ if type.blank?
+ summary << partial
+ on_partial_blk.call(partial, cancel) if on_partial_blk
+ end
+ end
+
+ bot.reply(context, &buffer_blk)
+
+ summary
end
def available_tokens
@@ -159,6 +154,12 @@ module DiscourseAi
item
end
+
+ def text_only_update(&on_partial_blk)
+ Proc.new do |partial, cancel, placeholder, type|
+ on_partial_blk.call(partial, cancel) if type.blank?
+ end
+ end
end
end
end
diff --git a/lib/summarization/strategies/base.rb b/lib/summarization/strategies/base.rb
index f9a5e182..c56f93f3 100644
--- a/lib/summarization/strategies/base.rb
+++ b/lib/summarization/strategies/base.rb
@@ -33,13 +33,8 @@ module DiscourseAi
raise NotImplementedError
end
- # @returns { DiscourseAi::Completions::Prompt } - Prompt passed to the LLM when extending an existing summary.
- def summary_extension_prompt(_summary, _texts_to_summarize)
- raise NotImplementedError
- end
-
- # @returns { DiscourseAi::Completions::Prompt } - Prompt passed to the LLM for summarizing a single chunk of content.
- def first_summary_prompt(_input)
+ # @returns { Array } - Prompt messages to send to the LLM for summarizing content.
+ def as_llm_messages(_input)
raise NotImplementedError
end
diff --git a/lib/summarization/strategies/chat_messages.rb b/lib/summarization/strategies/chat_messages.rb
index 20163464..d0d7ba68 100644
--- a/lib/summarization/strategies/chat_messages.rb
+++ b/lib/summarization/strategies/chat_messages.rb
@@ -27,70 +27,15 @@ module DiscourseAi
.map { { id: _1, poster: _2, text: _3, last_version_at: _4 } }
end
- def summary_extension_prompt(summary, contents)
- input =
- contents
- .map { |item| "(#{item[:id]} #{item[:poster]} said: #{item[:text]} " }
- .join("\n")
-
- prompt = DiscourseAi::Completions::Prompt.new(<<~TEXT.strip)
- You are a summarization bot tasked with expanding on an existing summary by incorporating new chat messages.
- Your goal is to seamlessly integrate the additional information into the existing summary, preserving the clarity and insights of the original while reflecting any new developments, themes, or conclusions.
- Analyze the new messages to identify key themes, participants' intentions, and any significant decisions or resolutions.
- Update the summary to include these aspects in a way that remains concise, comprehensive, and accessible to someone with no prior context of the conversation.
-
- ### Guidelines:
-
- - Merge the new information naturally with the existing summary without redundancy.
- - Only include the updated summary, WITHOUT additional commentary.
- - Don't mention the channel title. Avoid extraneous details or subjective opinions.
- - Maintain the original language of the text being summarized.
- - The same user could write multiple messages in a row, don't treat them as different persons.
- - Aim for summaries to be extended by a reasonable amount, but strive to maintain a total length of 400 words or less, unless absolutely necessary for comprehensiveness.
-
- TEXT
-
- prompt.push(type: :user, content: <<~TEXT.strip)
- ### Context:
-
- This is the existing summary:
-
- #{summary}
-
- These are the new chat messages:
-
- #{input}
-
- Intengrate the new messages into the existing summary.
- TEXT
-
- prompt
- end
-
- def first_summary_prompt(contents)
+ def as_llm_messages(contents)
content_title = target.name
input =
contents.map { |item| "(#{item[:id]} #{item[:poster]} said: #{item[:text]} " }.join
- prompt = DiscourseAi::Completions::Prompt.new(<<~TEXT.strip)
- You are a summarization bot designed to generate clear and insightful paragraphs that conveys the main topics
- and developments from a series of chat messages within a user-selected time window.
+ [{ type: :user, content: <<~TEXT.strip }]
+ #{content_title.present? ? "These texts come from a chat channel called " + content_title + ".\n" : ""}
- Analyze the messages to extract key themes, participants' intentions, and any significant conclusions or decisions.
- Your summary should be concise yet comprehensive, providing an overview that is accessible to someone with no prior context of the conversation.
-
- - Only include the summary, WITHOUT additional commentary.
- - Don't mention the channel title. Avoid including extraneous details or subjective opinions.
- - Maintain the original language of the text being summarized.
- - The same user could write multiple messages in a row, don't treat them as different persons.
- - Aim for summaries to be 400 words or less.
-
- TEXT
-
- prompt.push(type: :user, content: <<~TEXT.strip)
- #{content_title.present? ? "The name of the channel is: " + content_title + ".\n" : ""}
-
- Here are the messages, inside XML tags:
+ Here are the texts, inside XML tags:
#{input}
@@ -98,8 +43,6 @@ module DiscourseAi
Generate a summary of the given chat messages.
TEXT
-
- prompt
end
private
diff --git a/lib/summarization/strategies/hot_topic_gists.rb b/lib/summarization/strategies/hot_topic_gists.rb
index 24835817..3b7c7ceb 100644
--- a/lib/summarization/strategies/hot_topic_gists.rb
+++ b/lib/summarization/strategies/hot_topic_gists.rb
@@ -62,69 +62,11 @@ module DiscourseAi
end
end
- def summary_extension_prompt(summary, contents)
- statements =
- contents
- .to_a
- .map { |item| "(#{item[:id]} #{item[:poster]} said: #{item[:text]} " }
- .join("\n")
-
- prompt = DiscourseAi::Completions::Prompt.new(<<~TEXT.strip, topic_id: target.id)
- You are an advanced summarization bot. Your task is to update an existing single-sentence summary by integrating new developments from a conversation.
- Analyze the most recent messages to identify key updates or shifts in the main topic and reflect these in the updated summary.
- Emphasize new significant information or developments within the context of the initial conversation theme.
-
- ### Guidelines:
-
- - Ensure the revised summary remains concise and objective, maintaining a focus on the central theme or issue.
- - Omit 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 updated summary to a maximum of 40 words.
- - Return the 40-word summary inside tags.
-
- TEXT
-
- prompt.push(type: :user, content: <<~TEXT.strip)
- ### Context:
-
- This is the existing single-sentence summary:
-
- #{summary}
-
- And these are the new developments in the conversation:
-
- #{statements}
-
- Your task is to update an existing single-sentence summary by integrating new developments from a conversation.
- Return the 40-word summary inside tags.
- TEXT
-
- prompt
- end
-
- def first_summary_prompt(contents)
+ def as_llm_messages(contents)
content_title = target.title
statements =
contents.to_a.map { |item| "(#{item[:id]} #{item[:poster]} said: #{item[:text]} " }
- prompt = DiscourseAi::Completions::Prompt.new(<<~TEXT.strip, topic_id: target.id)
- 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 40 words.
- - Do *NOT* repeat the discussion title in the summary.
-
- Return the summary inside tags.\n
- TEXT
-
context = +<<~TEXT
### Context:
@@ -147,11 +89,9 @@ module DiscourseAi
context << "Your task is to capture the meaning of the initial statement."
end
- prompt.push(type: :user, content: <<~TEXT.strip)
+ [{ type: :user, content: <<~TEXT.strip }]
#{context} Return the 40-word summary inside tags.
TEXT
-
- prompt
end
end
end
diff --git a/lib/summarization/strategies/topic_summary.rb b/lib/summarization/strategies/topic_summary.rb
index 36a30496..9361e50b 100644
--- a/lib/summarization/strategies/topic_summary.rb
+++ b/lib/summarization/strategies/topic_summary.rb
@@ -38,82 +38,26 @@ module DiscourseAi
end
end
- def summary_extension_prompt(summary, contents)
- resource_path = "#{Discourse.base_path}/t/-/#{target.id}"
- content_title = target.title
- input =
- contents.map { |item| "(#{item[:id]} #{item[:poster]} said: #{item[:text]})" }.join
-
- prompt = DiscourseAi::Completions::Prompt.new(<<~TEXT, topic_id: target.id)
- You are an advanced summarization bot tasked with enhancing an existing summary by incorporating additional posts.
-
- ### Guidelines:
- - Only include the enhanced summary, without any additional commentary.
- - Understand and generate Discourse forum Markdown; including links, _italics_, **bold**.
- - Maintain the original language of the text being summarized.
- - Aim for summaries to be 400 words or less.
- - Each new post is formatted as ") "
- - Cite specific noteworthy posts using the format [DESCRIPTION](#{resource_path}/POST_NUMBER)
- - Example: links to the 3rd and 6th posts by sam: sam ([#3](#{resource_path}/3), [#6](#{resource_path}/6))
- - Example: link to the 6th post by jane: [agreed with](#{resource_path}/6)
- - Example: link to the 13th post by joe: [joe](#{resource_path}/13)
- - When formatting usernames either use @USERNAME or [USERNAME](#{resource_path}/POST_NUMBER)
- TEXT
-
- prompt.push(type: :user, content: <<~TEXT.strip)
- ### Context:
-
- #{content_title.present? ? "The discussion title is: " + content_title + ".\n" : ""}
-
- Here is the existing summary:
-
- #{summary}
-
- Here are the new posts, inside XML tags:
-
-
- #{input}
-
-
- Integrate the new information to generate an enhanced concise and coherent summary.
- TEXT
-
- prompt
- end
-
- def first_summary_prompt(contents)
+ def as_llm_messages(contents)
resource_path = "#{Discourse.base_path}/t/-/#{target.id}"
content_title = target.title
input =
contents.map { |item| "(#{item[:id]} #{item[:poster]} said: #{item[:text]} " }.join
- prompt = DiscourseAi::Completions::Prompt.new(<<~TEXT.strip, topic_id: target.id)
- You are an advanced summarization bot that generates concise, coherent summaries of provided text.
-
- - Only include the summary, without any additional commentary.
- - You understand and generate Discourse forum Markdown; including links, _italics_, **bold**.
- - Maintain the original language of the text being summarized.
- - Aim for summaries to be 400 words or less.
- - Each post is formatted as ") "
- - Cite specific noteworthy posts using the format [DESCRIPTION](#{resource_path}/POST_NUMBER)
- - Example: links to the 3rd and 6th posts by sam: sam ([#3](#{resource_path}/3), [#6](#{resource_path}/6))
- - Example: link to the 6th post by jane: [agreed with](#{resource_path}/6)
- - Example: link to the 13th post by joe: [joe](#{resource_path}/13)
- - When formatting usernames either use @USERNMAE OR [USERNAME](#{resource_path}/POST_NUMBER)
- TEXT
-
- prompt.push(
+ messages = []
+ messages << {
type: :user,
content:
"Here are the posts inside XML tags:\n\n1) user1 said: I love Mondays 2) user2 said: I hate Mondays\n\nGenerate a concise, coherent summary of the text above maintaining the original language.",
- )
- prompt.push(
+ }
+
+ messages << {
type: :model,
content:
"Two users are sharing their feelings toward Mondays. [user1](#{resource_path}/1) hates them, while [user2](#{resource_path}/2) loves them.",
- )
+ }
- prompt.push(type: :user, content: <<~TEXT.strip)
+ messages << { type: :user, content: <<~TEXT.strip }
#{content_title.present? ? "The discussion title is: " + content_title + ".\n" : ""}
Here are the posts, inside XML tags:
@@ -124,7 +68,7 @@ module DiscourseAi
Generate a concise, coherent summary of the text above maintaining the original language.
TEXT
- prompt
+ messages
end
private
diff --git a/spec/fabricators/ai_persona_fabricator.rb b/spec/fabricators/ai_persona_fabricator.rb
index d976c0e5..4b3c3f02 100644
--- a/spec/fabricators/ai_persona_fabricator.rb
+++ b/spec/fabricators/ai_persona_fabricator.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
Fabricator(:ai_persona) do
- name "test_bot"
+ name { sequence(:name) { |i| "persona_#{i}" } }
description "I am a test bot"
system_prompt "You are a test bot"
end
diff --git a/spec/lib/guardian_extensions_spec.rb b/spec/lib/guardian_extensions_spec.rb
index 7e3bb6f0..7c538f25 100644
--- a/spec/lib/guardian_extensions_spec.rb
+++ b/spec/lib/guardian_extensions_spec.rb
@@ -17,11 +17,9 @@ describe DiscourseAi::GuardianExtensions do
describe "#can_see_summary?" do
context "when the user cannot generate a summary" do
- before { SiteSetting.ai_custom_summarization_allowed_groups = "" }
+ before { assign_persona_to(:ai_summarization_persona, []) }
it "returns false" do
- SiteSetting.ai_custom_summarization_allowed_groups = ""
-
expect(guardian.can_see_summary?(topic)).to eq(false)
end
@@ -33,7 +31,7 @@ describe DiscourseAi::GuardianExtensions do
end
context "when the user can generate a summary" do
- before { SiteSetting.ai_custom_summarization_allowed_groups = group.id }
+ before { assign_persona_to(:ai_summarization_persona, [group.id]) }
it "returns true if the user group is present in the ai_custom_summarization_allowed_groups_map setting" do
expect(guardian.can_see_summary?(topic)).to eq(true)
@@ -41,7 +39,7 @@ describe DiscourseAi::GuardianExtensions do
end
context "when the topic is a PM" do
- before { SiteSetting.ai_custom_summarization_allowed_groups = group.id }
+ before { assign_persona_to(:ai_summarization_persona, [group.id]) }
let(:pm) { Fabricate(:private_message_topic) }
it "returns false" do
@@ -68,34 +66,34 @@ describe DiscourseAi::GuardianExtensions do
end
describe "#can_see_gists?" do
- before { SiteSetting.ai_summary_gists_allowed_groups = group.id }
+ before { assign_persona_to(:ai_summary_gists_persona, [group.id]) }
let(:guardian) { Guardian.new(user) }
- context "when there is no user" do
+ context "when access is restricted to the user's group" do
+ it "returns false when there is a user who is a member of an allowed group" do
+ expect(guardian.can_see_gists?).to eq(true)
+ end
+
it "returns false for anons" do
expect(anon_guardian.can_see_gists?).to eq(false)
end
+
+ it "returns false for non-group members" do
+ other_user_guardian = Guardian.new(Fabricate(:user))
+
+ expect(other_user_guardian.can_see_gists?).to eq(false)
+ end
end
- context "when setting is set to everyone" do
- before { SiteSetting.ai_summary_gists_allowed_groups = Group::AUTO_GROUPS[:everyone] }
+ context "when access is set to everyone" do
+ before { assign_persona_to(:ai_summary_gists_persona, []) }
it "returns true" do
expect(guardian.can_see_gists?).to eq(true)
end
- end
- context "when there is a user but it's not a member of the allowed groups" do
- before { SiteSetting.ai_summary_gists_allowed_groups = "" }
-
- it "returns false" do
- expect(guardian.can_see_gists?).to eq(false)
- end
- end
-
- context "when there is a user who is a member of an allowed group" do
- it "returns false" do
- expect(guardian.can_see_gists?).to eq(true)
+ it "returns false for anons" do
+ expect(anon_guardian.can_see_gists?).to eq(true)
end
end
end
diff --git a/spec/lib/modules/summarization/entry_point_spec.rb b/spec/lib/modules/summarization/entry_point_spec.rb
index 723eed69..0d57cbf9 100644
--- a/spec/lib/modules/summarization/entry_point_spec.rb
+++ b/spec/lib/modules/summarization/entry_point_spec.rb
@@ -63,7 +63,7 @@ RSpec.describe DiscourseAi::Summarization::EntryPoint do
before do
group.add(user)
- SiteSetting.ai_summary_gists_allowed_groups = group.id
+ assign_persona_to(:ai_summary_gists_persona, [group.id])
SiteSetting.ai_summary_gists_enabled = true
end
@@ -82,14 +82,14 @@ RSpec.describe DiscourseAi::Summarization::EntryPoint do
end
it "doesn't include the summary when the user is not a member of the opt-in group" do
- SiteSetting.ai_summary_gists_allowed_groups = ""
+ non_member_user = Fabricate(:user)
gist_topic = topic_query.list_hot.topics.find { |t| t.id == topic_ai_gist.target_id }
serialized =
TopicListItemSerializer.new(
gist_topic,
- scope: Guardian.new(user),
+ scope: Guardian.new(non_member_user),
root: false,
filter: :hot,
).as_json
diff --git a/spec/lib/modules/summarization/fold_content_spec.rb b/spec/lib/modules/summarization/fold_content_spec.rb
index 40bf9cd1..7f6fafaf 100644
--- a/spec/lib/modules/summarization/fold_content_spec.rb
+++ b/spec/lib/modules/summarization/fold_content_spec.rb
@@ -23,33 +23,17 @@ RSpec.describe DiscourseAi::Summarization::FoldContent do
llm_model.update!(max_prompt_tokens: model_tokens)
end
- let(:single_summary) { "single" }
- let(:concatenated_summary) { "this is a concatenated summary" }
+ let(:single_summary) { "this is a summary" }
fab!(:user)
- context "when the content to summarize fits in a single call" do
- it "does one call to summarize content" do
- result =
- DiscourseAi::Completions::Llm.with_prepared_responses([single_summary]) do |spy|
- summarizer.summarize(user).tap { expect(spy.completions).to eq(1) }
- end
+ it "summarizes the content" do
+ result =
+ DiscourseAi::Completions::Llm.with_prepared_responses([single_summary]) do |spy|
+ summarizer.summarize(user).tap { expect(spy.completions).to eq(1) }
+ end
- expect(result.summarized_text).to eq(single_summary)
- end
- end
-
- context "when the content to summarize doesn't fit in a single call" do
- fab!(:post_2) { Fabricate(:post, topic: topic, post_number: 2, raw: "This is a text") }
-
- it "keeps extending the summary until there is nothing else to process" do
- result =
- DiscourseAi::Completions::Llm.with_prepared_responses(
- [single_summary, concatenated_summary],
- ) { |spy| summarizer.summarize(user).tap { expect(spy.completions).to eq(2) } }
-
- expect(result.summarized_text).to eq(concatenated_summary)
- end
+ expect(result.summarized_text).to eq(single_summary)
end
end
diff --git a/spec/lib/personas/bot_spec.rb b/spec/lib/personas/bot_spec.rb
index 03619957..5578f8b7 100644
--- a/spec/lib/personas/bot_spec.rb
+++ b/spec/lib/personas/bot_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
RSpec.describe DiscourseAi::Personas::Bot do
- subject(:bot) { described_class.as(bot_user) }
+ subject(:bot) { described_class.as(bot_user, persona: DiscourseAi::Personas::General.new) }
fab!(:admin)
fab!(:gpt_4) { Fabricate(:llm_model, name: "gpt-4") }
diff --git a/spec/plugin_helper.rb b/spec/plugin_helper.rb
index 2c7d9d7a..0346df7c 100644
--- a/spec/plugin_helper.rb
+++ b/spec/plugin_helper.rb
@@ -15,6 +15,12 @@ module DiscourseAi::ChatBotHelper
SiteSetting.public_send("#{setting_name}=", "custom:#{fake_llm.id}")
end
end
+
+ def assign_persona_to(setting_name, allowed_group_ids)
+ Fabricate(:ai_persona, allowed_group_ids: allowed_group_ids).tap do |p|
+ SiteSetting.public_send("#{setting_name}=", p.id)
+ end
+ end
end
RSpec.configure { |config| config.include DiscourseAi::ChatBotHelper }
diff --git a/spec/system/summarization/chat_summarization_spec.rb b/spec/system/summarization/chat_summarization_spec.rb
index 6a54647c..dbb78417 100644
--- a/spec/system/summarization/chat_summarization_spec.rb
+++ b/spec/system/summarization/chat_summarization_spec.rb
@@ -12,8 +12,8 @@ RSpec.describe "Summarize a channel since your last visit", type: :system do
group.add(current_user)
assign_fake_provider_to(:ai_summarization_model)
+ assign_persona_to(:ai_summarization_persona, [group.id])
SiteSetting.ai_summarization_enabled = true
- SiteSetting.ai_custom_summarization_allowed_groups = group.id.to_s
SiteSetting.chat_enabled = true
SiteSetting.chat_allowed_groups = group.id.to_s
diff --git a/spec/system/summarization/topic_summarization_spec.rb b/spec/system/summarization/topic_summarization_spec.rb
index 45ffd7a6..8a23772b 100644
--- a/spec/system/summarization/topic_summarization_spec.rb
+++ b/spec/system/summarization/topic_summarization_spec.rb
@@ -22,8 +22,8 @@ RSpec.describe "Summarize a topic ", type: :system do
group.add(current_user)
assign_fake_provider_to(:ai_summarization_model)
+ assign_persona_to(:ai_summarization_persona, [group.id])
SiteSetting.ai_summarization_enabled = true
- SiteSetting.ai_custom_summarization_allowed_groups = group.id.to_s
sign_in(current_user)
end