FEATURE: Personas powered summaries. (#1232)

* REFACTOR: Move personas into it's own module.

* WIP: Use personas for summarization

* Prioritize persona default LLM or fallback to newest one

* Simplify summarization strategy

* Keep ai_sumarization_model as a fallback
This commit is contained in:
Roman Rizzi 2025-04-02 12:54:47 -03:00 committed by GitHub
parent 32da999144
commit 0d60aca6ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 333 additions and 353 deletions

View File

@ -47,9 +47,9 @@ module DiscourseAi
def discover def discover
ai_persona = ai_persona =
AiPersona.all_personas.find do |persona| AiPersona
persona.id == SiteSetting.ai_bot_discover_persona.to_i .all_personas(enabled_only: false)
end .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) if ai_persona.nil? || !current_user.in_any_groups?(ai_persona.allowed_group_ids.to_a)
raise Discourse::InvalidAccess.new raise Discourse::InvalidAccess.new

View File

@ -9,9 +9,9 @@ module Jobs
return if (query = args[:query]).blank? return if (query = args[:query]).blank?
ai_persona_klass = ai_persona_klass =
AiPersona.all_personas.find do |persona| AiPersona
persona.id == SiteSetting.ai_bot_discover_persona.to_i .all_personas(enabled_only: false)
end .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) if ai_persona_klass.nil? || !user.in_any_groups?(ai_persona_klass.allowed_group_ids.to_a)
return return

View File

@ -46,13 +46,18 @@ class AiPersona < ActiveRecord::Base
scope :ordered, -> { order("priority DESC, lower(name) ASC") } scope :ordered, -> { order("priority DESC, lower(name) ASC") }
def self.all_personas def self.all_personas(enabled_only: true)
persona_cache[:value] ||= AiPersona persona_cache[:value] ||= AiPersona
.ordered .ordered
.where(enabled: true)
.all .all
.limit(MAX_PERSONAS_PER_SITE) .limit(MAX_PERSONAS_PER_SITE)
.map(&:class_instance) .map(&:class_instance)
if enabled_only
persona_cache[:value].select { |p| p.enabled }
else
persona_cache[:value]
end
end end
def self.persona_users(user: nil) def self.persona_users(user: nil)
@ -176,6 +181,7 @@ class AiPersona < ActiveRecord::Base
description description
allowed_group_ids allowed_group_ids
tool_details tool_details
enabled
] ]
instance_attributes = {} instance_attributes = {}

View File

@ -303,6 +303,12 @@ en:
web_artifact_creator: web_artifact_creator:
name: "Web Artifact Creator" name: "Web Artifact Creator"
description: "AI Bot specialized in creating interactive web artifacts" 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!" topic_not_found: "Summary unavailable, topic not found!"
summarizing: "Summarizing topic" summarizing: "Summarizing topic"
searching: "Searching for: '%{query}'" searching: "Searching for: '%{query}'"
@ -452,6 +458,7 @@ en:
llm: llm:
configuration: configuration:
create_llm: "You need to setup an LLM before enabling this feature"
disable_module_first: "You have to disable %{setting} first." disable_module_first: "You have to disable %{setting} first."
set_llm_first: "Set %{setting} first" set_llm_first: "Set %{setting} first"
model_unreachable: "We couldn't get a response from this model. Check your settings first." model_unreachable: "We couldn't get a response from this model. Check your settings first."

View File

@ -240,18 +240,30 @@ discourse_ai:
type: enum type: enum
enum: "DiscourseAi::Configuration::LlmEnumerator" enum: "DiscourseAi::Configuration::LlmEnumerator"
validator: "DiscourseAi::Configuration::LlmValidator" validator: "DiscourseAi::Configuration::LlmValidator"
hidden: true
ai_summarization_persona:
default: "-11"
type: enum
enum: "DiscourseAi::Configuration::PersonaEnumerator"
ai_pm_summarization_allowed_groups: ai_pm_summarization_allowed_groups:
type: group_list type: group_list
list_type: compact list_type: compact
default: "" default: ""
ai_custom_summarization_allowed_groups: ai_custom_summarization_allowed_groups: # Deprecated. TODO(roman): Remove 2025-09-01
type: group_list type: group_list
list_type: compact list_type: compact
default: "3|13" # 3: @staff, 13: @trust_level_3 default: "3|13" # 3: @staff, 13: @trust_level_3
hidden: true
ai_summary_gists_enabled: ai_summary_gists_enabled:
default: false default: false
hidden: true 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 type: group_list
list_type: compact list_type: compact
default: "0" #everyone default: "0" #everyone

View File

@ -1,17 +1,39 @@
# frozen_string_literal: true # 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| DiscourseAi::Personas::Persona.system_personas.each do |persona_class, id|
persona = AiPersona.find_by(id: id) persona = AiPersona.find_by(id: id)
if !persona if !persona
persona = AiPersona.new persona = AiPersona.new
persona.id = id persona.id = id
if persona_class == DiscourseAi::Personas::WebArtifactCreator if persona_class == DiscourseAi::Personas::WebArtifactCreator
# this is somewhat sensitive, so we default it to staff # this is somewhat sensitive, so we default it to staff
persona.allowed_group_ids = [Group::AUTO_GROUPS[: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 else
persona.allowed_group_ids = [Group::AUTO_GROUPS[:trust_level_0]] persona.allowed_group_ids = [Group::AUTO_GROUPS[:trust_level_0]]
end end
persona.enabled = true
persona.enabled = !summarization_personas.include?(persona_class)
persona.priority = true if persona_class == DiscourseAi::Personas::General persona.priority = true if persona_class == DiscourseAi::Personas::General
end end

View File

@ -10,17 +10,27 @@ module DiscourseAi
def valid_value?(val) def valid_value?(val)
return true if val == "f" return true if val == "f"
if @opts[:name] == :ai_summarization_enabled
has_llms = LlmModel.count > 0
@no_llms_configured = !has_llms
has_llms
else
@llm_dependency_setting_name = @llm_dependency_setting_name =
DiscourseAi::Configuration::LlmValidator.new.choose_llm_setting_for(@opts[: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
end
def error_message def error_message
if @llm_dependency_setting_name
I18n.t( I18n.t(
"discourse_ai.llm.configuration.set_llm_first", "discourse_ai.llm.configuration.set_llm_first",
setting: @llm_dependency_setting_name, setting: @llm_dependency_setting_name,
) )
elsif @no_llms_configured
I18n.t("discourse_ai.llm.configuration.create_llm")
end
end end
end end
end end

View File

@ -26,7 +26,9 @@ module DiscourseAi
end end
if SiteSetting.ai_summarization_enabled 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 } rval[model_id] << { type: :ai_summarization }
end end

View File

@ -10,7 +10,9 @@ module DiscourseAi
end end
def self.values 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 end
end end

View File

@ -6,7 +6,7 @@ module DiscourseAi
def initialize(body) def initialize(body)
@persona = @persona =
AiPersona AiPersona
.all_personas .all_personas(enabled_only: false)
.find { |persona| persona.id == SiteSetting.ai_discord_search_persona.to_i } .find { |persona| persona.id == SiteSetting.ai_discord_search_persona.to_i }
.new .new
@bot = @bot =

View File

@ -24,24 +24,26 @@ module DiscourseAi
def can_see_gists? def can_see_gists?
return false if !SiteSetting.ai_summarization_enabled return false if !SiteSetting.ai_summarization_enabled
return false if !SiteSetting.ai_summary_gists_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| if (ai_persona = AiPersona.find_by(id: SiteSetting.ai_summary_gists_persona)).blank?
user.group_ids.include?(group_id) return false
end 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 end
def can_request_summary? def can_request_summary?
return false if anonymous? return false if anonymous?
user_group_ids = user.group_ids user_group_ids = user.group_ids
SiteSetting.ai_custom_summarization_allowed_groups_map.any? do |group_id| if (ai_persona = AiPersona.find_by(id: SiteSetting.ai_summarization_persona)).blank?
user_group_ids.include?(group_id) return false
end end
ai_persona.allowed_group_ids.to_a.any? { |group_id| user.group_ids.include?(group_id) }
end end
def can_debug_ai_bot_conversation?(target) def can_debug_ai_bot_conversation?(target)

View File

@ -28,7 +28,7 @@ module DiscourseAi
attr_accessor :persona attr_accessor :persona
def llm def llm
@llm ||= DiscourseAi::Completions::Llm.proxy(model) DiscourseAi::Completions::Llm.proxy(model)
end end
def force_tool_if_needed(prompt, context) def force_tool_if_needed(prompt, context)
@ -51,12 +51,12 @@ module DiscourseAi
end end
end end
def reply(context, &update_blk) def reply(context, llm_args: {}, &update_blk)
unless context.is_a?(BotContext) unless context.is_a?(BotContext)
raise ArgumentError, "context must be an instance of BotContext" raise ArgumentError, "context must be an instance of BotContext"
end end
llm = DiscourseAi::Completions::Llm.proxy(model) current_llm = llm
prompt = persona.craft_prompt(context, llm: llm) prompt = persona.craft_prompt(context, llm: current_llm)
total_completions = 0 total_completions = 0
ongoing_chain = true ongoing_chain = true
@ -67,6 +67,7 @@ module DiscourseAi
llm_kwargs = { user: user } llm_kwargs = { user: user }
llm_kwargs[:temperature] = persona.temperature if persona.temperature llm_kwargs[:temperature] = persona.temperature if persona.temperature
llm_kwargs[:top_p] = persona.top_p if persona.top_p 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 needs_newlines = false
tools_ran = 0 tools_ran = 0
@ -82,9 +83,9 @@ module DiscourseAi
current_thinking = [] current_thinking = []
result = result =
llm.generate( current_llm.generate(
prompt, prompt,
feature_name: "bot", feature_name: context.feature_name,
partial_tool_calls: allow_partial_tool_calls, partial_tool_calls: allow_partial_tool_calls,
output_thinking: true, output_thinking: true,
**llm_kwargs, **llm_kwargs,
@ -93,7 +94,7 @@ module DiscourseAi
persona.find_tool( persona.find_tool(
partial, partial,
bot_user: user, bot_user: user,
llm: llm, llm: current_llm,
context: context, context: context,
existing_tools: existing_tools, existing_tools: existing_tools,
) )
@ -120,7 +121,7 @@ module DiscourseAi
process_tool( process_tool(
tool: tool, tool: tool,
raw_context: raw_context, raw_context: raw_context,
llm: llm, current_llm: current_llm,
cancel: cancel, cancel: cancel,
update_blk: update_blk, update_blk: update_blk,
prompt: prompt, prompt: prompt,
@ -204,7 +205,7 @@ module DiscourseAi
def process_tool( def process_tool(
tool:, tool:,
raw_context:, raw_context:,
llm:, current_llm:,
cancel:, cancel:,
update_blk:, update_blk:,
prompt:, prompt:,
@ -212,7 +213,7 @@ module DiscourseAi
current_thinking: current_thinking:
) )
tool_call_id = tool.tool_call_id 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 = { tool_call_message = {
type: :tool_call, type: :tool_call,
@ -246,7 +247,7 @@ module DiscourseAi
raw_context << [invocation_result_json, tool_call_id, "tool", tool.name] raw_context << [invocation_result_json, tool_call_id, "tool", tool.name]
end 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? show_placeholder = !context.skip_tool_details && !tool.class.allow_partial_tool_calls?
update_blk.call("", cancel, build_placeholder(tool.summary, "")) if show_placeholder update_blk.call("", cancel, build_placeholder(tool.summary, "")) if show_placeholder

View File

@ -14,7 +14,9 @@ module DiscourseAi
:chosen_tools, :chosen_tools,
:message_id, :message_id,
:channel_id, :channel_id,
:context_post_ids :context_post_ids,
:feature_name,
:resource_url
def initialize( def initialize(
post: nil, post: nil,
@ -29,7 +31,9 @@ module DiscourseAi
time: nil, time: nil,
message_id: nil, message_id: nil,
channel_id: nil, channel_id: nil,
context_post_ids: nil context_post_ids: nil,
feature_name: "bot",
resource_url: nil
) )
@participants = participants @participants = participants
@user = user @user = user
@ -45,6 +49,7 @@ module DiscourseAi
@site_title = site_title @site_title = site_title
@site_description = site_description @site_description = site_description
@time = time @time = time
@feature_name = feature_name
if post if post
@post_id = post.id @post_id = post.id
@ -56,7 +61,7 @@ module DiscourseAi
end end
# these are strings that can be safely interpolated into templates # 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) def lookup_template_param(key)
public_send(key.to_sym) if TEMPLATE_PARAMS.include?(key) public_send(key.to_sym) if TEMPLATE_PARAMS.include?(key)
@ -100,6 +105,8 @@ module DiscourseAi
site_title: @site_title, site_title: @site_title,
site_description: @site_description, site_description: @site_description,
skip_tool_details: @skip_tool_details, skip_tool_details: @skip_tool_details,
feature_name: @feature_name,
resource_url: @resource_url,
} }
end end
end end

View File

@ -44,6 +44,8 @@ module DiscourseAi
DiscourseHelper => -8, DiscourseHelper => -8,
GithubHelper => -9, GithubHelper => -9,
WebArtifactCreator => -10, WebArtifactCreator => -10,
Summarizer => -11,
ShortSummarizer => -12,
} }
end end

View File

@ -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 <ai></ai> tags.
PROMPT
end
end
end
end

View File

@ -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 "<POST_NUMBER>) <USERNAME> <MESSAGE>"
- 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

View File

@ -2,37 +2,78 @@
module DiscourseAi module DiscourseAi
module Summarization module Summarization
def self.topic_summary(topic) class << self
if SiteSetting.ai_summarization_model.present? && SiteSetting.ai_summarization_enabled 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::Summarization::FoldContent.new(
DiscourseAi::Completions::Llm.proxy(SiteSetting.ai_summarization_model), build_bot(persona_klass, llm_model),
DiscourseAi::Summarization::Strategies::TopicSummary.new(topic), DiscourseAi::Summarization::Strategies::TopicSummary.new(topic),
) )
else
nil
end
end end
def self.topic_gist(topic) def topic_gist(topic)
if SiteSetting.ai_summarization_model.present? && SiteSetting.ai_summarization_enabled 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::Summarization::FoldContent.new(
DiscourseAi::Completions::Llm.proxy(SiteSetting.ai_summarization_model), build_bot(persona_klass, llm_model),
DiscourseAi::Summarization::Strategies::HotTopicGists.new(topic), DiscourseAi::Summarization::Strategies::HotTopicGists.new(topic),
) )
else
nil
end
end end
def self.chat_channel_summary(channel, time_window_in_hours) def chat_channel_summary(channel, time_window_in_hours)
if SiteSetting.ai_summarization_model.present? && SiteSetting.ai_summarization_enabled 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::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), DiscourseAi::Summarization::Strategies::ChatMessages.new(channel, time_window_in_hours),
persist_summaries: false, persist_summaries: false,
) )
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 else
nil 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 end
end end

View File

@ -6,7 +6,11 @@ module DiscourseAi
def inject_into(plugin) def inject_into(plugin)
plugin.add_to_serializer(:current_user, :can_summarize) do plugin.add_to_serializer(:current_user, :can_summarize) do
return false if !SiteSetting.ai_summarization_enabled 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 end
plugin.add_to_serializer(:topic_view, :summarizable) do plugin.add_to_serializer(:topic_view, :summarizable) do

View File

@ -9,13 +9,13 @@ module DiscourseAi
# into a final version. # into a final version.
# #
class FoldContent class FoldContent
def initialize(llm, strategy, persist_summaries: true) def initialize(bot, strategy, persist_summaries: true)
@llm = llm @bot = bot
@strategy = strategy @strategy = strategy
@persist_summaries = persist_summaries @persist_summaries = persist_summaries
end end
attr_reader :llm, :strategy attr_reader :bot, :strategy
# @param user { User } - User object used for auditing usage. # @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. # @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. # @returns { AiSummary } - Resulting summary.
def summarize(user, &on_partial_blk) def summarize(user, &on_partial_blk)
base_summary = ""
initial_pos = 0
truncated_content = content_to_summarize.map { |cts| truncate(cts) } 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 = clean_summary = Nokogiri::HTML5.fragment(summary).css("ai")&.first&.text || summary
Nokogiri::HTML5.fragment(folded_summary).css("ai")&.first&.text || folded_summary
if persist_summaries if persist_summaries
AiSummary.store!( AiSummary.store!(
@ -76,7 +72,7 @@ module DiscourseAi
attr_reader :persist_summaries attr_reader :persist_summaries
def llm_model def llm_model
llm.llm_model bot.llm.llm_model
end end
def content_to_summarize def content_to_summarize
@ -88,54 +84,53 @@ module DiscourseAi
end end
# @param items { Array<Hash> } - Content to summarize. Structure will be: { poster: who wrote the content, id: a way to order content, text: content } # @param items { Array<Hash> } - 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 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. # @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. # Note: The block is only called with results of the final summary, not intermediate summaries.
# #
# The summarization algorithm. # 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 # It will summarize as much content summarize given the model's context window. If will prioriotize newer content in case it doesn't fit.
# content until there is nothing left.
# #
# @returns { String } - Resulting summary. # @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 tokenizer = llm_model.tokenizer_class
tokens_left = available_tokens - tokenizer.size(summary) tokens_left = available_tokens
iteration_content = [] content_in_window = []
items.each_with_index do |item, idx| items.each_with_index do |item, idx|
next if idx < cursor
as_text = "(#{item[:id]} #{item[:poster]} said: #{item[:text]} " as_text = "(#{item[:id]} #{item[:poster]} said: #{item[:text]} "
if tokenizer.below_limit?(as_text, tokens_left) if tokenizer.below_limit?(as_text, tokens_left)
iteration_content << item content_in_window << item
tokens_left -= tokenizer.size(as_text) tokens_left -= tokenizer.size(as_text)
cursor += 1
else else
break break
end end
end end
prompt = context =
( DiscourseAi::Personas::BotContext.new(
if summary.blank? user: user,
strategy.first_summary_prompt(iteration_content) skip_tool_details: true,
else feature_name: strategy.feature,
strategy.summary_extension_prompt(summary, iteration_content) resource_url: "#{Discourse.base_path}/t/-/#{strategy.target.id}",
end messages: strategy.as_llm_messages(content_in_window),
) )
if cursor == items.length summary = +""
llm.generate(prompt, user: user, feature_name: strategy.feature, &on_partial_blk) buffer_blk =
else Proc.new do |partial, cancel, placeholder, type|
latest_summary = if type.blank?
llm.generate(prompt, user: user, max_tokens: 600, feature_name: strategy.feature) summary << partial
fold(items, latest_summary, cursor, user, &on_partial_blk) on_partial_blk.call(partial, cancel) if on_partial_blk
end end
end end
bot.reply(context, &buffer_blk)
summary
end
def available_tokens def available_tokens
# Reserve tokens for the response and the base prompt # Reserve tokens for the response and the base prompt
# ~500 words # ~500 words
@ -159,6 +154,12 @@ module DiscourseAi
item item
end 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 end
end end

View File

@ -33,13 +33,8 @@ module DiscourseAi
raise NotImplementedError raise NotImplementedError
end end
# @returns { DiscourseAi::Completions::Prompt } - Prompt passed to the LLM when extending an existing summary. # @returns { Array } - Prompt messages to send to the LLM for summarizing content.
def summary_extension_prompt(_summary, _texts_to_summarize) def as_llm_messages(_input)
raise NotImplementedError
end
# @returns { DiscourseAi::Completions::Prompt } - Prompt passed to the LLM for summarizing a single chunk of content.
def first_summary_prompt(_input)
raise NotImplementedError raise NotImplementedError
end end

View File

@ -27,70 +27,15 @@ module DiscourseAi
.map { { id: _1, poster: _2, text: _3, last_version_at: _4 } } .map { { id: _1, poster: _2, text: _3, last_version_at: _4 } }
end end
def summary_extension_prompt(summary, contents) def as_llm_messages(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)
content_title = target.name content_title = target.name
input = input =
contents.map { |item| "(#{item[:id]} #{item[:poster]} said: #{item[:text]} " }.join contents.map { |item| "(#{item[:id]} #{item[:poster]} said: #{item[:text]} " }.join
prompt = DiscourseAi::Completions::Prompt.new(<<~TEXT.strip) [{ type: :user, content: <<~TEXT.strip }]
You are a summarization bot designed to generate clear and insightful paragraphs that conveys the main topics #{content_title.present? ? "These texts come from a chat channel called " + content_title + ".\n" : ""}
and developments from a series of chat messages within a user-selected time window.
Analyze the messages to extract key themes, participants' intentions, and any significant conclusions or decisions. Here are the texts, inside <input></input> XML tags:
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 <input></input> XML tags:
<input> <input>
#{input} #{input}
@ -98,8 +43,6 @@ module DiscourseAi
Generate a summary of the given chat messages. Generate a summary of the given chat messages.
TEXT TEXT
prompt
end end
private private

View File

@ -62,69 +62,11 @@ module DiscourseAi
end end
end end
def summary_extension_prompt(summary, contents) def as_llm_messages(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 <ai></ai> 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 <ai></ai> tags.
TEXT
prompt
end
def first_summary_prompt(contents)
content_title = target.title content_title = target.title
statements = statements =
contents.to_a.map { |item| "(#{item[:id]} #{item[:poster]} said: #{item[:text]} " } 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 <ai></ai> tags.\n
TEXT
context = +<<~TEXT context = +<<~TEXT
### Context: ### Context:
@ -147,11 +89,9 @@ module DiscourseAi
context << "Your task is to capture the meaning of the initial statement." context << "Your task is to capture the meaning of the initial statement."
end end
prompt.push(type: :user, content: <<~TEXT.strip) [{ type: :user, content: <<~TEXT.strip }]
#{context} Return the 40-word summary inside <ai></ai> tags. #{context} Return the 40-word summary inside <ai></ai> tags.
TEXT TEXT
prompt
end end
end end
end end

View File

@ -38,82 +38,26 @@ module DiscourseAi
end end
end end
def summary_extension_prompt(summary, 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, 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 "<POST_NUMBER>) <USERNAME> <MESSAGE>"
- 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 <input></input> XML tags:
<input>
#{input}
</input>
Integrate the new information to generate an enhanced concise and coherent summary.
TEXT
prompt
end
def first_summary_prompt(contents)
resource_path = "#{Discourse.base_path}/t/-/#{target.id}" resource_path = "#{Discourse.base_path}/t/-/#{target.id}"
content_title = target.title content_title = target.title
input = input =
contents.map { |item| "(#{item[:id]} #{item[:poster]} said: #{item[:text]} " }.join contents.map { |item| "(#{item[:id]} #{item[:poster]} said: #{item[:text]} " }.join
prompt = DiscourseAi::Completions::Prompt.new(<<~TEXT.strip, topic_id: target.id) messages = []
You are an advanced summarization bot that generates concise, coherent summaries of provided text. messages << {
- 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 "<POST_NUMBER>) <USERNAME> <MESSAGE>"
- 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(
type: :user, type: :user,
content: content:
"Here are the posts inside <input></input> XML tags:\n\n<input>1) user1 said: I love Mondays 2) user2 said: I hate Mondays</input>\n\nGenerate a concise, coherent summary of the text above maintaining the original language.", "Here are the posts inside <input></input> XML tags:\n\n<input>1) user1 said: I love Mondays 2) user2 said: I hate Mondays</input>\n\nGenerate a concise, coherent summary of the text above maintaining the original language.",
) }
prompt.push(
messages << {
type: :model, type: :model,
content: content:
"Two users are sharing their feelings toward Mondays. [user1](#{resource_path}/1) hates them, while [user2](#{resource_path}/2) loves them.", "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" : ""} #{content_title.present? ? "The discussion title is: " + content_title + ".\n" : ""}
Here are the posts, inside <input></input> XML tags: Here are the posts, inside <input></input> XML tags:
@ -124,7 +68,7 @@ module DiscourseAi
Generate a concise, coherent summary of the text above maintaining the original language. Generate a concise, coherent summary of the text above maintaining the original language.
TEXT TEXT
prompt messages
end end
private private

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
Fabricator(:ai_persona) do Fabricator(:ai_persona) do
name "test_bot" name { sequence(:name) { |i| "persona_#{i}" } }
description "I am a test bot" description "I am a test bot"
system_prompt "You are a test bot" system_prompt "You are a test bot"
end end

View File

@ -17,11 +17,9 @@ describe DiscourseAi::GuardianExtensions do
describe "#can_see_summary?" do describe "#can_see_summary?" do
context "when the user cannot generate a 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 it "returns false" do
SiteSetting.ai_custom_summarization_allowed_groups = ""
expect(guardian.can_see_summary?(topic)).to eq(false) expect(guardian.can_see_summary?(topic)).to eq(false)
end end
@ -33,7 +31,7 @@ describe DiscourseAi::GuardianExtensions do
end end
context "when the user can generate a summary" do 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 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) expect(guardian.can_see_summary?(topic)).to eq(true)
@ -41,7 +39,7 @@ describe DiscourseAi::GuardianExtensions do
end end
context "when the topic is a PM" do 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) } let(:pm) { Fabricate(:private_message_topic) }
it "returns false" do it "returns false" do
@ -68,34 +66,34 @@ describe DiscourseAi::GuardianExtensions do
end end
describe "#can_see_gists?" do 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) } 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 it "returns false for anons" do
expect(anon_guardian.can_see_gists?).to eq(false) expect(anon_guardian.can_see_gists?).to eq(false)
end 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 end
context "when setting is set to everyone" do context "when access is set to everyone" do
before { SiteSetting.ai_summary_gists_allowed_groups = Group::AUTO_GROUPS[:everyone] } before { assign_persona_to(:ai_summary_gists_persona, []) }
it "returns true" do it "returns true" do
expect(guardian.can_see_gists?).to eq(true) expect(guardian.can_see_gists?).to eq(true)
end end
end
context "when there is a user but it's not a member of the allowed groups" do it "returns false for anons" do
before { SiteSetting.ai_summary_gists_allowed_groups = "" } expect(anon_guardian.can_see_gists?).to eq(true)
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)
end end
end end
end end

View File

@ -63,7 +63,7 @@ RSpec.describe DiscourseAi::Summarization::EntryPoint do
before do before do
group.add(user) 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 SiteSetting.ai_summary_gists_enabled = true
end end
@ -82,14 +82,14 @@ RSpec.describe DiscourseAi::Summarization::EntryPoint do
end end
it "doesn't include the summary when the user is not a member of the opt-in group" do 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 } gist_topic = topic_query.list_hot.topics.find { |t| t.id == topic_ai_gist.target_id }
serialized = serialized =
TopicListItemSerializer.new( TopicListItemSerializer.new(
gist_topic, gist_topic,
scope: Guardian.new(user), scope: Guardian.new(non_member_user),
root: false, root: false,
filter: :hot, filter: :hot,
).as_json ).as_json

View File

@ -23,13 +23,11 @@ RSpec.describe DiscourseAi::Summarization::FoldContent do
llm_model.update!(max_prompt_tokens: model_tokens) llm_model.update!(max_prompt_tokens: model_tokens)
end end
let(:single_summary) { "single" } let(:single_summary) { "this is a summary" }
let(:concatenated_summary) { "this is a concatenated summary" }
fab!(:user) fab!(:user)
context "when the content to summarize fits in a single call" do it "summarizes the content" do
it "does one call to summarize content" do
result = result =
DiscourseAi::Completions::Llm.with_prepared_responses([single_summary]) do |spy| DiscourseAi::Completions::Llm.with_prepared_responses([single_summary]) do |spy|
summarizer.summarize(user).tap { expect(spy.completions).to eq(1) } summarizer.summarize(user).tap { expect(spy.completions).to eq(1) }
@ -39,20 +37,6 @@ RSpec.describe DiscourseAi::Summarization::FoldContent do
end end
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
end
end
describe "#existing_summary" do describe "#existing_summary" do
context "when a summary already exists" do context "when a summary already exists" do
fab!(:ai_summary) do fab!(:ai_summary) do

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
RSpec.describe DiscourseAi::Personas::Bot do 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!(:admin)
fab!(:gpt_4) { Fabricate(:llm_model, name: "gpt-4") } fab!(:gpt_4) { Fabricate(:llm_model, name: "gpt-4") }

View File

@ -15,6 +15,12 @@ module DiscourseAi::ChatBotHelper
SiteSetting.public_send("#{setting_name}=", "custom:#{fake_llm.id}") SiteSetting.public_send("#{setting_name}=", "custom:#{fake_llm.id}")
end end
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 end
RSpec.configure { |config| config.include DiscourseAi::ChatBotHelper } RSpec.configure { |config| config.include DiscourseAi::ChatBotHelper }

View File

@ -12,8 +12,8 @@ RSpec.describe "Summarize a channel since your last visit", type: :system do
group.add(current_user) group.add(current_user)
assign_fake_provider_to(:ai_summarization_model) assign_fake_provider_to(:ai_summarization_model)
assign_persona_to(:ai_summarization_persona, [group.id])
SiteSetting.ai_summarization_enabled = true SiteSetting.ai_summarization_enabled = true
SiteSetting.ai_custom_summarization_allowed_groups = group.id.to_s
SiteSetting.chat_enabled = true SiteSetting.chat_enabled = true
SiteSetting.chat_allowed_groups = group.id.to_s SiteSetting.chat_allowed_groups = group.id.to_s

View File

@ -22,8 +22,8 @@ RSpec.describe "Summarize a topic ", type: :system do
group.add(current_user) group.add(current_user)
assign_fake_provider_to(:ai_summarization_model) assign_fake_provider_to(:ai_summarization_model)
assign_persona_to(:ai_summarization_persona, [group.id])
SiteSetting.ai_summarization_enabled = true SiteSetting.ai_summarization_enabled = true
SiteSetting.ai_custom_summarization_allowed_groups = group.id.to_s
sign_in(current_user) sign_in(current_user)
end end