mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-07-05 05:52:16 +00:00
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:
parent
32da999144
commit
0d60aca6ef
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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 = {}
|
||||||
|
@ -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."
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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 =
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -44,6 +44,8 @@ module DiscourseAi
|
|||||||
DiscourseHelper => -8,
|
DiscourseHelper => -8,
|
||||||
GithubHelper => -9,
|
GithubHelper => -9,
|
||||||
WebArtifactCreator => -10,
|
WebArtifactCreator => -10,
|
||||||
|
Summarizer => -11,
|
||||||
|
ShortSummarizer => -12,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
26
lib/personas/short_summarizer.rb
Normal file
26
lib/personas/short_summarizer.rb
Normal 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
|
25
lib/personas/summarizer.rb
Normal file
25
lib/personas/summarizer.rb
Normal 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
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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") }
|
||||||
|
@ -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 }
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user