FEATURE: LLM based peroidical summary report (#357)
Introduce a Discourse Automation based periodical report. Depends on Discourse Automation. Report works best with very large context language models such as GPT-4-Turbo and Claude 2. - Introduces final_insts to generic llm format, for claude to work best it is better to guide the last assistant message (we should add this to other spots as well) - Adds GPT-4 turbo support to generic llm interface
This commit is contained in:
parent
e0bf6adb5b
commit
d0f54443ae
|
@ -6,12 +6,59 @@ en:
|
|||
discourse_ai: "Discourse AI"
|
||||
js:
|
||||
discourse_automation:
|
||||
ai_models:
|
||||
gpt_4_turbo: GPT 4 Turbo
|
||||
gpt_4: GPT 4
|
||||
gpt_3_5_turbo: GPT 3.5 Turbo
|
||||
claude_2: Claude 2
|
||||
gemini_pro: Gemini Pro
|
||||
scriptables:
|
||||
llm_report:
|
||||
fields:
|
||||
sender:
|
||||
label: "Sender"
|
||||
description: "The user that will send the report"
|
||||
receivers:
|
||||
label: "Receivers"
|
||||
description: "The users that will receive the report (can be email or usernames)"
|
||||
title:
|
||||
label: "Title"
|
||||
description: "The title of the report"
|
||||
days:
|
||||
label: "Days"
|
||||
description: "The timespan of the report"
|
||||
offset:
|
||||
label: "Offset"
|
||||
description: "When testing you may want to run the report historically, use offset to start the report in an earlier date"
|
||||
instructions:
|
||||
label: "Instructions"
|
||||
description: "The instructions provided to the large language model"
|
||||
sample_size:
|
||||
label: "Sample Size"
|
||||
description: "The number of posts to sample for the report"
|
||||
tokens_per_post:
|
||||
label: "Tokens per post"
|
||||
description: "The number of llm tokens to use per post"
|
||||
model:
|
||||
label: "Model"
|
||||
description: "LLM to use for report generation"
|
||||
categories:
|
||||
label: "Categories"
|
||||
description: "Filter topics only to these category"
|
||||
tags:
|
||||
label: "Tags"
|
||||
description: "Filter topics only to these tags"
|
||||
allow_secure_categories:
|
||||
label: "Allow secure categories"
|
||||
description: "Allow the report to be generated for topics in secure categories"
|
||||
debug_mode:
|
||||
label: "Debug Mode"
|
||||
description: "Enable debug mode to see the raw input and output of the LLM"
|
||||
priority_group:
|
||||
label: "Priority Group"
|
||||
description: "Priotize content from this group in the report"
|
||||
|
||||
llm_triage:
|
||||
models:
|
||||
gpt_4: GPT 4
|
||||
gpt_3_5_turbo: GPT 3.5 Turbo
|
||||
claude_2: Claude 2
|
||||
fields:
|
||||
system_prompt:
|
||||
label: "System Prompt"
|
||||
|
|
|
@ -5,6 +5,9 @@ en:
|
|||
title: Triage posts using AI
|
||||
description: "Triage posts using a large language model"
|
||||
system_prompt_missing_post_placeholder: "System prompt must contain a placeholder for the post: %%POST%%"
|
||||
llm_report:
|
||||
title: Periodic report using AI
|
||||
description: "Periodic report based on a large language model"
|
||||
site_settings:
|
||||
discourse_ai_enabled: "Enable the discourse AI plugin."
|
||||
ai_toxicity_enabled: "Enable the toxicity module."
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
if defined?(DiscourseAutomation)
|
||||
module DiscourseAutomation::LlmReport
|
||||
end
|
||||
|
||||
DiscourseAutomation::Scriptable::LLM_REPORT = "llm_report"
|
||||
|
||||
DiscourseAutomation::Scriptable.add(DiscourseAutomation::Scriptable::LLM_REPORT) do
|
||||
version 1
|
||||
triggerables %i[recurring]
|
||||
|
||||
field :sender, component: :user, required: true
|
||||
field :receivers, component: :users, required: true
|
||||
field :title, component: :text, required: true
|
||||
field :days, component: :text, required: true, default_value: 7
|
||||
field :offset, component: :text, required: true, default_value: 0
|
||||
field :instructions,
|
||||
component: :message,
|
||||
required: true,
|
||||
default_value: DiscourseAi::Automation::ReportRunner.default_instructions
|
||||
field :sample_size, component: :text, required: true, default_value: 100
|
||||
field :tokens_per_post, component: :text, required: true, default_value: 150
|
||||
|
||||
field :model,
|
||||
component: :choices,
|
||||
required: true,
|
||||
extra: {
|
||||
content: DiscourseAi::Automation::AVAILABLE_MODELS,
|
||||
}
|
||||
|
||||
field :priority_group, component: :group
|
||||
field :categories, component: :categories
|
||||
field :tags, component: :tags
|
||||
|
||||
field :allow_secure_categories, component: :boolean
|
||||
field :debug_mode, component: :boolean
|
||||
|
||||
script do |context, fields, automation|
|
||||
begin
|
||||
sender = fields.dig("sender", "value")
|
||||
receivers = fields.dig("receivers", "value")
|
||||
title = fields.dig("title", "value")
|
||||
model = fields.dig("model", "value")
|
||||
category_ids = fields.dig("categories", "value")
|
||||
tags = fields.dig("tags", "value")
|
||||
allow_secure_categories = !!fields.dig("allow_secure_categories", "value")
|
||||
debug_mode = !!fields.dig("debug_mode", "value")
|
||||
sample_size = fields.dig("sample_size", "value")
|
||||
instructions = fields.dig("instructions", "value")
|
||||
days = fields.dig("days", "value")
|
||||
offset = fields.dig("offset", "value").to_i
|
||||
priority_group = fields.dig("priority_group", "value")
|
||||
tokens_per_post = fields.dig("tokens_per_post", "value")
|
||||
|
||||
DiscourseAi::Automation::ReportRunner.run!(
|
||||
sender_username: sender,
|
||||
receivers: receivers,
|
||||
title: title,
|
||||
model: model,
|
||||
category_ids: category_ids,
|
||||
tags: tags,
|
||||
allow_secure_categories: allow_secure_categories,
|
||||
debug_mode: debug_mode,
|
||||
sample_size: sample_size,
|
||||
instructions: instructions,
|
||||
days: days,
|
||||
offset: offset,
|
||||
priority_group_id: priority_group,
|
||||
tokens_per_post: tokens_per_post,
|
||||
)
|
||||
rescue => e
|
||||
Discourse.warn_exception e, message: "Error running LLM report!"
|
||||
if Rails.env.development?
|
||||
p e
|
||||
puts e.backtrace
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,104 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
if defined?(DiscourseAutomation)
|
||||
module DiscourseAutomation::LlmTriage
|
||||
def self.handle(
|
||||
post:,
|
||||
model:,
|
||||
search_for_text:,
|
||||
system_prompt:,
|
||||
category_id: nil,
|
||||
tags: nil,
|
||||
canned_reply: nil,
|
||||
canned_reply_user: nil,
|
||||
hide_topic: nil
|
||||
)
|
||||
if category_id.blank? && tags.blank? && canned_reply.blank? && hide_topic.blank?
|
||||
raise ArgumentError, "llm_triage: no action specified!"
|
||||
end
|
||||
|
||||
post_template = +""
|
||||
post_template << "title: #{post.topic.title}\n"
|
||||
post_template << "#{post.raw}"
|
||||
|
||||
filled_system_prompt = system_prompt.sub("%%POST%%", post_template)
|
||||
|
||||
if filled_system_prompt == system_prompt
|
||||
raise ArgumentError, "llm_triage: system_prompt does not contain %%POST%% placeholder"
|
||||
end
|
||||
|
||||
result = nil
|
||||
if model == "claude-2"
|
||||
# allowing double + 10 tokens
|
||||
# technically maybe just token count is fine, but this will allow for more creative bad responses
|
||||
result =
|
||||
DiscourseAi::Inference::AnthropicCompletions.perform!(
|
||||
filled_system_prompt,
|
||||
model,
|
||||
temperature: 0,
|
||||
max_tokens:
|
||||
DiscourseAi::Tokenizer::AnthropicTokenizer.tokenize(search_for_text).length * 2 + 10,
|
||||
).dig(:completion)
|
||||
else
|
||||
result =
|
||||
DiscourseAi::Inference::OpenAiCompletions.perform!(
|
||||
[{ :role => "system", "content" => filled_system_prompt }],
|
||||
model,
|
||||
temperature: 0,
|
||||
max_tokens:
|
||||
DiscourseAi::Tokenizer::OpenAiTokenizer.tokenize(search_for_text).length * 2 + 10,
|
||||
).dig(:choices, 0, :message, :content)
|
||||
end
|
||||
|
||||
if result.strip == search_for_text.strip
|
||||
user = User.find_by_username(canned_reply_user) if canned_reply_user.present?
|
||||
user = user || Discourse.system_user
|
||||
if canned_reply.present?
|
||||
PostCreator.create!(
|
||||
user,
|
||||
topic_id: post.topic_id,
|
||||
raw: canned_reply,
|
||||
reply_to_post_number: post.post_number,
|
||||
skip_validations: true,
|
||||
)
|
||||
end
|
||||
|
||||
changes = {}
|
||||
changes[:category_id] = category_id if category_id.present?
|
||||
changes[:tags] = tags if SiteSetting.tagging_enabled? && tags.present?
|
||||
|
||||
if changes.present?
|
||||
first_post = post.topic.posts.where(post_number: 1).first
|
||||
changes[:bypass_bump] = true
|
||||
changes[:skip_validations] = true
|
||||
first_post.revise(Discourse.system_user, changes)
|
||||
end
|
||||
|
||||
post.topic.update!(visible: false) if hide_topic
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
DiscourseAutomation::Scriptable::LLM_TRIAGE = "llm_triage"
|
||||
|
||||
AVAILABLE_MODELS = [
|
||||
{
|
||||
id: "gpt-4",
|
||||
name:
|
||||
"discourse_automation.scriptables.#{DiscourseAutomation::Scriptable::LLM_TRIAGE}.models.gpt_4",
|
||||
},
|
||||
{
|
||||
id: "gpt-3-5-turbo",
|
||||
name:
|
||||
"discourse_automation.scriptables.#{DiscourseAutomation::Scriptable::LLM_TRIAGE}.models.gpt_3_5_turbo",
|
||||
},
|
||||
{
|
||||
id: "claude-2",
|
||||
name:
|
||||
"discourse_automation.scriptables.#{DiscourseAutomation::Scriptable::LLM_TRIAGE}.models.claude_2",
|
||||
},
|
||||
]
|
||||
|
||||
DiscourseAutomation::Scriptable.add(DiscourseAutomation::Scriptable::LLM_TRIAGE) do
|
||||
version 1
|
||||
run_in_background
|
||||
|
@ -119,7 +23,12 @@ if defined?(DiscourseAutomation)
|
|||
end,
|
||||
accepts_placeholders: true
|
||||
field :search_for_text, component: :text, required: true
|
||||
field :model, component: :choices, required: true, extra: { content: AVAILABLE_MODELS }
|
||||
field :model,
|
||||
component: :choices,
|
||||
required: true,
|
||||
extra: {
|
||||
content: DiscourseAi::Automation::AVAILABLE_MODELS,
|
||||
}
|
||||
field :category, component: :category
|
||||
field :tags, component: :tags
|
||||
field :hide_topic, component: :boolean
|
||||
|
@ -149,7 +58,7 @@ if defined?(DiscourseAutomation)
|
|||
end
|
||||
|
||||
begin
|
||||
DiscourseAutomation::LlmTriage.handle(
|
||||
DiscourseAi::Automation::LlmTriage.handle(
|
||||
post: post,
|
||||
model: model,
|
||||
search_for_text: search_for_text,
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAi
|
||||
module Automation
|
||||
AVAILABLE_MODELS = [
|
||||
{ id: "gpt-4-turbo", name: "discourse_automation.ai_models.gpt_4_turbo" },
|
||||
{ id: "gpt-4", name: "discourse_automation.ai_models.gpt_4" },
|
||||
{ id: "gpt-3-5-turbo", name: "discourse_automation.ai_models.gpt_3_5_turbo" },
|
||||
{ id: "claude-2", name: "discourse_automation.ai_models.claude_2" },
|
||||
{ id: "gemini-pro", name: "discourse_automation.ai_models.gemini_pro" },
|
||||
]
|
||||
end
|
||||
end
|
|
@ -0,0 +1,75 @@
|
|||
# frozen_string_literal: true
|
||||
#
|
||||
module DiscourseAi
|
||||
module Automation
|
||||
module LlmTriage
|
||||
def self.handle(
|
||||
post:,
|
||||
model:,
|
||||
search_for_text:,
|
||||
system_prompt:,
|
||||
category_id: nil,
|
||||
tags: nil,
|
||||
canned_reply: nil,
|
||||
canned_reply_user: nil,
|
||||
hide_topic: nil
|
||||
)
|
||||
if category_id.blank? && tags.blank? && canned_reply.blank? && hide_topic.blank?
|
||||
raise ArgumentError, "llm_triage: no action specified!"
|
||||
end
|
||||
|
||||
post_template = +""
|
||||
post_template << "title: #{post.topic.title}\n"
|
||||
post_template << "#{post.raw}"
|
||||
|
||||
filled_system_prompt = system_prompt.sub("%%POST%%", post_template)
|
||||
|
||||
if filled_system_prompt == system_prompt
|
||||
raise ArgumentError, "llm_triage: system_prompt does not contain %%POST%% placeholder"
|
||||
end
|
||||
|
||||
result = nil
|
||||
|
||||
llm = DiscourseAi::Completions::Llm.proxy(model)
|
||||
prompt = {
|
||||
insts: filled_system_prompt,
|
||||
params: {
|
||||
model => {
|
||||
max_tokens: (llm.tokenizer.tokenize(search_for_text).length * 2 + 10),
|
||||
temperature: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result = llm.completion!(prompt, Discourse.system_user)
|
||||
|
||||
if result.strip == search_for_text.strip
|
||||
user = User.find_by_username(canned_reply_user) if canned_reply_user.present?
|
||||
user = user || Discourse.system_user
|
||||
if canned_reply.present?
|
||||
PostCreator.create!(
|
||||
user,
|
||||
topic_id: post.topic_id,
|
||||
raw: canned_reply,
|
||||
reply_to_post_number: post.post_number,
|
||||
skip_validations: true,
|
||||
)
|
||||
end
|
||||
|
||||
changes = {}
|
||||
changes[:category_id] = category_id if category_id.present?
|
||||
changes[:tags] = tags if SiteSetting.tagging_enabled? && tags.present?
|
||||
|
||||
if changes.present?
|
||||
first_post = post.topic.posts.where(post_number: 1).first
|
||||
changes[:bypass_bump] = true
|
||||
changes[:skip_validations] = true
|
||||
first_post.revise(Discourse.system_user, changes)
|
||||
end
|
||||
|
||||
post.topic.update!(visible: false) if hide_topic
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,225 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAi
|
||||
module Automation
|
||||
class ReportContextGenerator
|
||||
def self.generate(**args)
|
||||
new(**args).generate
|
||||
end
|
||||
|
||||
def initialize(
|
||||
start_date:,
|
||||
duration:,
|
||||
category_ids: nil,
|
||||
tags: nil,
|
||||
allow_secure_categories: false,
|
||||
max_posts: 200,
|
||||
tokens_per_post: 100,
|
||||
tokenizer: nil,
|
||||
prioritized_group_ids: []
|
||||
)
|
||||
@start_date = start_date
|
||||
@duration = duration
|
||||
@category_ids = category_ids
|
||||
@tags = tags
|
||||
@allow_secure_categories = allow_secure_categories
|
||||
@max_posts = max_posts
|
||||
@tokenizer = tokenizer || DiscourseAi::Tokenizer::OpenAiTokenizer
|
||||
@tokens_per_post = tokens_per_post
|
||||
@prioritized_group_ids = prioritized_group_ids
|
||||
|
||||
@posts =
|
||||
Post
|
||||
.where("posts.created_at >= ?", @start_date)
|
||||
.joins(topic: :category)
|
||||
.includes(:topic, :user)
|
||||
.where("posts.created_at < ?", @start_date + @duration)
|
||||
.where("posts.post_type = ?", Post.types[:regular])
|
||||
.where("posts.hidden_at IS NULL")
|
||||
.where("topics.deleted_at IS NULL")
|
||||
.where("topics.archetype = ?", Archetype.default)
|
||||
@posts = @posts.where("categories.read_restricted = ?", false) if !@allow_secure_categories
|
||||
@posts = @posts.where("categories.id IN (?)", @category_ids) if @category_ids.present?
|
||||
|
||||
if @tags.present?
|
||||
tag_ids = Tag.where(name: @tags).select(:id)
|
||||
topic_ids_with_tags = TopicTag.where(tag_id: tag_ids).select(:topic_id)
|
||||
@posts = @posts.where(topic_id: topic_ids_with_tags)
|
||||
end
|
||||
|
||||
@solutions = {}
|
||||
if defined?(::DiscourseSolved)
|
||||
TopicCustomField
|
||||
.where(name: ::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD)
|
||||
.where(topic_id: @posts.select(:topic_id))
|
||||
.pluck(:topic_id, :value)
|
||||
.each do |topic_id, post_id|
|
||||
@solutions[topic_id] ||= Set.new
|
||||
@solutions[topic_id] << post_id.to_i
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def format_topic(topic)
|
||||
info = []
|
||||
info << ""
|
||||
info << "### #{topic.title}"
|
||||
info << "topic_id: #{topic.id}"
|
||||
info << "solved: true" if @solutions.key?(topic.id)
|
||||
info << "category: #{topic.category&.name}"
|
||||
tags = topic.tags.pluck(:name)
|
||||
info << "tags: #{topic.tags.pluck(:name).join(", ")}" if tags.present?
|
||||
info << topic.created_at.strftime("%Y-%m-%d %H:%M")
|
||||
{ created_at: topic.created_at, info: info.join("\n"), posts: {} }
|
||||
end
|
||||
|
||||
def format_post(post)
|
||||
buffer = []
|
||||
buffer << ""
|
||||
buffer << "post_number: #{post.post_number}"
|
||||
if @solutions.key?(post.topic_id) && @solutions[post.topic_id].include?(post.id)
|
||||
buffer << "solution: true"
|
||||
end
|
||||
buffer << post.created_at.strftime("%Y-%m-%d %H:%M")
|
||||
buffer << "user: #{post.user&.username}"
|
||||
buffer << "likes: #{post.like_count}"
|
||||
excerpt = @tokenizer.truncate(post.raw, @tokens_per_post)
|
||||
excerpt = "excerpt: #{excerpt}..." if excerpt.length < post.raw.length
|
||||
buffer << "#{excerpt}"
|
||||
{ likes: post.like_count, info: buffer.join("\n") }
|
||||
end
|
||||
|
||||
def format_summary
|
||||
topic_count =
|
||||
@posts
|
||||
.where("topics.created_at > ?", @start_date)
|
||||
.select(:topic_id)
|
||||
.distinct(:topic_id)
|
||||
.count
|
||||
|
||||
buffer = []
|
||||
buffer << "Start Date: #{@start_date.to_date}"
|
||||
buffer << "End Date: #{(@start_date + @duration).to_date}"
|
||||
buffer << "New posts: #{@posts.count}"
|
||||
buffer << "New topics: #{topic_count}"
|
||||
|
||||
top_users =
|
||||
Post
|
||||
.where(id: @posts.select(:id))
|
||||
.joins(:user)
|
||||
.group(:user_id, :username)
|
||||
.select(
|
||||
"user_id, username, sum(posts.like_count) like_count, count(posts.id) post_count",
|
||||
)
|
||||
.order("sum(posts.like_count) desc")
|
||||
.limit(10)
|
||||
|
||||
buffer << "Top users:"
|
||||
top_users.each do |user|
|
||||
buffer << "@#{user.username} (#{user.like_count} likes, #{user.post_count} posts)"
|
||||
end
|
||||
|
||||
if @prioritized_group_ids.present?
|
||||
group_names =
|
||||
Group
|
||||
.where(id: @prioritized_group_ids)
|
||||
.pluck(:name, :full_name)
|
||||
.map do |name, full_name|
|
||||
if full_name.present?
|
||||
"#{name} (#{full_name[0..100].gsub("\n", " ")})"
|
||||
else
|
||||
name
|
||||
end
|
||||
end
|
||||
.join(", ")
|
||||
buffer << ""
|
||||
buffer << "Top users in #{group_names} group#{group_names.include?(",") ? "s" : ""}:"
|
||||
|
||||
group_users = GroupUser.where(group_id: @prioritized_group_ids).select(:user_id)
|
||||
top_users
|
||||
.where(user_id: group_users)
|
||||
.each do |user|
|
||||
buffer << "@#{user.username} (#{user.like_count} likes, #{user.post_count} posts)"
|
||||
end
|
||||
end
|
||||
|
||||
buffer.join("\n")
|
||||
end
|
||||
|
||||
def format_topics
|
||||
buffer = []
|
||||
topics = {}
|
||||
|
||||
post_count = 0
|
||||
|
||||
@posts = @posts.order("posts.like_count desc, posts.created_at desc")
|
||||
|
||||
if @prioritized_group_ids.present?
|
||||
user_groups = GroupUser.where(group_id: @prioritized_group_ids)
|
||||
prioritized_posts = @posts.where(user_id: user_groups.select(:user_id)).limit(@max_posts)
|
||||
|
||||
post_count += add_posts(prioritized_posts, topics)
|
||||
end
|
||||
|
||||
add_posts(@posts.limit(@max_posts), topics, limit: @max_posts - post_count)
|
||||
|
||||
# we need last posts in all topics
|
||||
# they may have important info
|
||||
last_posts =
|
||||
@posts.where("posts.post_number = topics.highest_post_number").where(
|
||||
"topics.id IN (?)",
|
||||
topics.keys,
|
||||
)
|
||||
|
||||
add_posts(last_posts, topics)
|
||||
|
||||
topics.each do |topic_id, topic_info|
|
||||
topic_info[:post_likes] = topic_info[:posts].sum { |_, post_info| post_info[:likes] }
|
||||
end
|
||||
|
||||
topics = topics.sort { |a, b| b[1][:post_likes] <=> a[1][:post_likes] }
|
||||
|
||||
topics.each do |topic_id, topic_info|
|
||||
buffer << topic_info[:info]
|
||||
|
||||
last_post_number = 0
|
||||
|
||||
topic_info[:posts]
|
||||
.sort { |a, b| a[0] <=> b[0] }
|
||||
.each do |post_number, post_info|
|
||||
buffer << "\n..." if post_number > last_post_number + 1
|
||||
buffer << post_info[:info]
|
||||
last_post_number = post_number
|
||||
end
|
||||
end
|
||||
|
||||
buffer.join("\n")
|
||||
end
|
||||
|
||||
def generate
|
||||
buffer = []
|
||||
|
||||
buffer << "## Summary"
|
||||
buffer << format_summary
|
||||
buffer << "\n## Topics"
|
||||
buffer << format_topics
|
||||
|
||||
buffer.join("\n")
|
||||
end
|
||||
|
||||
def add_posts(relation, topics, limit: nil)
|
||||
post_count = 0
|
||||
relation.each do |post|
|
||||
topics[post.topic_id] ||= format_topic(post.topic)
|
||||
if !topics[post.topic_id][:posts][post.post_number]
|
||||
topics[post.topic_id][:posts][post.post_number] = format_post(post)
|
||||
post_count += 1
|
||||
limit -= 1 if limit
|
||||
end
|
||||
break if limit && limit <= 0
|
||||
end
|
||||
post_count
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,146 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAi
|
||||
module Automation
|
||||
class ReportRunner
|
||||
def self.default_instructions
|
||||
# not localizing for now cause non English LLM will require
|
||||
# a fair bit of experimentation
|
||||
<<~TEXT
|
||||
Generate report:
|
||||
|
||||
## Report Guidelines:
|
||||
|
||||
- Length & Style: Aim for 12 dense paragraphs in a narrative style, focusing on internal forum discussions.
|
||||
- Accuracy: Only include verified information with no embellishments.
|
||||
- Sourcing: ALWAYS Back statements with links to forum discussions.
|
||||
- Markdown Usage: Enhance readability with **bold**, *italic*, and > quotes.
|
||||
- Linking: Use `#{Discourse.base_url}/t/-/TOPIC_ID/POST_NUMBER` for direct references.
|
||||
- User Mentions: Reference users with @USERNAME
|
||||
- Context tips: Staff are denoted with Username *. For example: jane * means that jane is a staff member. Do not render the * in the report.
|
||||
- Add many topic links: strive to link to at least 30 topics in the report. Topic Id is meaningless to end users if you need to throw in a link use [ref](...) or better still just embed it into the [sentence](...)
|
||||
- Categories and tags: use the format #TAG and #CATEGORY to denote tags and categories
|
||||
|
||||
## Structure:
|
||||
|
||||
- Key statistics: Specify date range, call out important stats like number of new topics and posts
|
||||
- Overview: Briefly state trends within period.
|
||||
- Highlighted content: 5 paragaraphs highlighting important topics people should know about. If possible have each paragraph link to multiple related topics.
|
||||
- Key insights and trends linking to a selection of posts that back them
|
||||
TEXT
|
||||
end
|
||||
|
||||
def self.run!(**args)
|
||||
new(**args).run!
|
||||
end
|
||||
|
||||
def initialize(
|
||||
sender_username:,
|
||||
receivers:,
|
||||
title:,
|
||||
model:,
|
||||
category_ids:,
|
||||
tags:,
|
||||
allow_secure_categories:,
|
||||
debug_mode:,
|
||||
sample_size:,
|
||||
instructions:,
|
||||
days:,
|
||||
offset:,
|
||||
priority_group_id:,
|
||||
tokens_per_post:
|
||||
)
|
||||
@sender = User.find_by(username: sender_username)
|
||||
@receivers = User.where(username: receivers)
|
||||
@title = title
|
||||
|
||||
@model = model
|
||||
@llm = DiscourseAi::Completions::Llm.proxy(model)
|
||||
@category_ids = category_ids
|
||||
@tags = tags
|
||||
@allow_secure_categories = allow_secure_categories
|
||||
@debug_mode = debug_mode
|
||||
@sample_size = sample_size.to_i < 10 ? 10 : sample_size.to_i
|
||||
@instructions = instructions
|
||||
@days = days.to_i
|
||||
@offset = offset.to_i
|
||||
@priority_group_id = priority_group_id
|
||||
@tokens_per_post = tokens_per_post.to_i
|
||||
end
|
||||
|
||||
def run!
|
||||
start_date = (@offset + @days).days.ago
|
||||
prioritized_group_ids = [@priority_group_id] if @priority_group_id.present?
|
||||
context =
|
||||
DiscourseAi::Automation::ReportContextGenerator.generate(
|
||||
start_date: start_date,
|
||||
duration: @days.days,
|
||||
max_posts: @sample_size,
|
||||
tags: @tags,
|
||||
category_ids: @category_ids,
|
||||
prioritized_group_ids: prioritized_group_ids,
|
||||
allow_secure_categories: @allow_secure_categories,
|
||||
tokens_per_post: @tokens_per_post,
|
||||
tokenizer: @llm.tokenizer,
|
||||
)
|
||||
input = <<~INPUT
|
||||
#{@instructions}
|
||||
|
||||
<context>
|
||||
#{context}
|
||||
</context>
|
||||
|
||||
#{@instructions}
|
||||
INPUT
|
||||
|
||||
prompt = {
|
||||
insts: "You are a helpful bot specializing in summarizing activity Discourse sites",
|
||||
input: input,
|
||||
final_insts: "Here is the report I generated for you",
|
||||
params: {
|
||||
@model => {
|
||||
temperature: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result = +""
|
||||
|
||||
puts if Rails.env.development? && @debug_mode
|
||||
|
||||
@llm.completion!(prompt, Discourse.system_user) do |response|
|
||||
print response if Rails.env.development? && @debug_mode
|
||||
result << response
|
||||
end
|
||||
|
||||
post =
|
||||
PostCreator.create!(
|
||||
@sender,
|
||||
raw: result,
|
||||
title: @title,
|
||||
archetype: Archetype.private_message,
|
||||
target_usernames: @receivers.map(&:username).join(","),
|
||||
skip_validations: true,
|
||||
)
|
||||
|
||||
if @debug_mode
|
||||
input = input.split("\n").map { |line| " #{line}" }.join("\n")
|
||||
raw = <<~RAW
|
||||
```
|
||||
start_date: #{start_date},
|
||||
duration: #{@days.days},
|
||||
max_posts: #{@sample_size},
|
||||
tags: #{@tags},
|
||||
category_ids: #{@category_ids},
|
||||
priority_group: #{@priority_group_id}
|
||||
LLM context was:
|
||||
```
|
||||
|
||||
#{input}
|
||||
RAW
|
||||
PostCreator.create!(@sender, raw: raw, topic_id: post.topic_id, skip_validations: true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -6,7 +6,14 @@ module DiscourseAi
|
|||
class ChatGpt < Dialect
|
||||
class << self
|
||||
def can_translate?(model_name)
|
||||
%w[gpt-3.5-turbo gpt-4 gpt-3.5-turbo-16k gpt-4-32k].include?(model_name)
|
||||
%w[
|
||||
gpt-3.5-turbo
|
||||
gpt-4
|
||||
gpt-3.5-turbo-16k
|
||||
gpt-4-32k
|
||||
gpt-4-1106-preview
|
||||
gpt-4-turbo
|
||||
].include?(model_name)
|
||||
end
|
||||
|
||||
def tokenizer
|
||||
|
|
|
@ -27,7 +27,9 @@ module DiscourseAi
|
|||
|
||||
claude_prompt << "#{prompt[:post_insts]}\n" if prompt[:post_insts]
|
||||
|
||||
claude_prompt << "Assistant:\n"
|
||||
claude_prompt << "Assistant:"
|
||||
claude_prompt << " #{prompt[:final_insts]}:" if prompt[:final_insts]
|
||||
claude_prompt << "\n"
|
||||
end
|
||||
|
||||
def max_prompt_tokens
|
||||
|
|
|
@ -17,9 +17,10 @@ module DiscourseAi
|
|||
DiscourseAi::Completions::Dialects::OrcaStyle,
|
||||
DiscourseAi::Completions::Dialects::Gemini,
|
||||
]
|
||||
dialects.detect(-> { raise DiscourseAi::Completions::Llm::UNKNOWN_MODEL }) do |d|
|
||||
d.can_translate?(model_name)
|
||||
end
|
||||
|
||||
dialect = dialects.find { |d| d.can_translate?(model_name) }
|
||||
raise DiscourseAi::Completions::Llm::UNKNOWN_MODEL if !dialect
|
||||
dialect
|
||||
end
|
||||
|
||||
def tokenizer
|
||||
|
|
|
@ -5,11 +5,18 @@ module DiscourseAi
|
|||
module Endpoints
|
||||
class OpenAi < Base
|
||||
def self.can_contact?(model_name)
|
||||
%w[gpt-3.5-turbo gpt-4 gpt-3.5-turbo-16k gpt-4-32k].include?(model_name)
|
||||
%w[
|
||||
gpt-3.5-turbo
|
||||
gpt-4
|
||||
gpt-3.5-turbo-16k
|
||||
gpt-4-32k
|
||||
gpt-4-1106-preview
|
||||
gpt-4-turbo
|
||||
].include?(model_name)
|
||||
end
|
||||
|
||||
def default_options
|
||||
{ model: model }
|
||||
{ model: model == "gpt-4-turbo" ? "gpt-4-1106-preview" : model }
|
||||
end
|
||||
|
||||
def provider_id
|
||||
|
@ -24,7 +31,11 @@ module DiscourseAi
|
|||
if model.include?("32k")
|
||||
SiteSetting.ai_openai_gpt4_32k_url
|
||||
else
|
||||
SiteSetting.ai_openai_gpt4_url
|
||||
if model.include?("1106") || model.include?("turbo")
|
||||
SiteSetting.ai_openai_gpt4_turbo_url
|
||||
else
|
||||
SiteSetting.ai_openai_gpt4_url
|
||||
end
|
||||
end
|
||||
else
|
||||
if model.include?("16k")
|
||||
|
|
|
@ -7,6 +7,7 @@ module DiscourseAi
|
|||
foldable_models = [
|
||||
Models::OpenAi.new("gpt-4", max_tokens: 8192),
|
||||
Models::OpenAi.new("gpt-4-32k", max_tokens: 32_768),
|
||||
Models::OpenAi.new("gpt-4-1106-preview", max_tokens: 100_000),
|
||||
Models::OpenAi.new("gpt-3.5-turbo", max_tokens: 4096),
|
||||
Models::OpenAi.new("gpt-3.5-turbo-16k", max_tokens: 16_384),
|
||||
Models::Anthropic.new("claude-2", max_tokens: 100_000),
|
||||
|
|
|
@ -40,6 +40,7 @@ register_svg_icon "meh"
|
|||
after_initialize do
|
||||
# do not autoload this cause we may have no namespace
|
||||
require_relative "discourse_automation/llm_triage"
|
||||
require_relative "discourse_automation/llm_report"
|
||||
|
||||
add_admin_route "discourse_ai.title", "discourse-ai"
|
||||
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
return if !defined?(DiscourseAutomation)
|
||||
|
||||
describe DiscourseAutomation do
|
||||
let(:automation) { Fabricate(:automation, script: "llm_report", enabled: true) }
|
||||
|
||||
def add_automation_field(name, value, type: "text")
|
||||
automation.fields.create!(
|
||||
component: type,
|
||||
name: name,
|
||||
metadata: {
|
||||
value: value,
|
||||
},
|
||||
target: "script",
|
||||
)
|
||||
end
|
||||
|
||||
it "can trigger via automation" do
|
||||
user = Fabricate(:user)
|
||||
|
||||
add_automation_field("sender", user.username, type: "user")
|
||||
add_automation_field("receivers", [user.username], type: "users")
|
||||
add_automation_field("model", "gpt-4-turbo")
|
||||
add_automation_field("title", "Weekly report")
|
||||
|
||||
DiscourseAi::Completions::Llm.with_prepared_responses(["An Amazing Report!!!"]) do
|
||||
automation.trigger!
|
||||
end
|
||||
|
||||
pm = Topic.where(title: "Weekly report").first
|
||||
expect(pm.posts.first.raw).to eq("An Amazing Report!!!")
|
||||
end
|
||||
end
|
|
@ -2,106 +2,9 @@
|
|||
|
||||
return if !defined?(DiscourseAutomation)
|
||||
|
||||
describe DiscourseAutomation::LlmTriage do
|
||||
describe DiscourseAi::Automation::LlmTriage do
|
||||
fab!(:post) { Fabricate(:post) }
|
||||
|
||||
def triage(**args)
|
||||
DiscourseAutomation::LlmTriage.handle(**args)
|
||||
end
|
||||
|
||||
it "does nothing if it does not pass triage" do
|
||||
stub_request(:post, "https://api.openai.com/v1/chat/completions").to_return(
|
||||
status: 200,
|
||||
body: { choices: [{ message: { content: "good" } }] }.to_json,
|
||||
)
|
||||
|
||||
triage(
|
||||
post: post,
|
||||
model: "gpt-4",
|
||||
hide_topic: true,
|
||||
system_prompt: "test %%POST%%",
|
||||
search_for_text: "bad",
|
||||
)
|
||||
|
||||
expect(post.topic.reload.visible).to eq(true)
|
||||
end
|
||||
|
||||
it "can hide topics on triage with claude" do
|
||||
stub_request(:post, "https://api.anthropic.com/v1/complete").to_return(
|
||||
status: 200,
|
||||
body: { completion: "bad" }.to_json,
|
||||
)
|
||||
|
||||
triage(
|
||||
post: post,
|
||||
model: "claude-2",
|
||||
hide_topic: true,
|
||||
system_prompt: "test %%POST%%",
|
||||
search_for_text: "bad",
|
||||
)
|
||||
|
||||
expect(post.topic.reload.visible).to eq(false)
|
||||
end
|
||||
|
||||
it "can hide topics on triage with claude" do
|
||||
stub_request(:post, "https://api.openai.com/v1/chat/completions").to_return(
|
||||
status: 200,
|
||||
body: { choices: [{ message: { content: "bad" } }] }.to_json,
|
||||
)
|
||||
|
||||
triage(
|
||||
post: post,
|
||||
model: "gpt-4",
|
||||
hide_topic: true,
|
||||
system_prompt: "test %%POST%%",
|
||||
search_for_text: "bad",
|
||||
)
|
||||
|
||||
expect(post.topic.reload.visible).to eq(false)
|
||||
end
|
||||
|
||||
it "can categorize topics on triage" do
|
||||
category = Fabricate(:category)
|
||||
|
||||
stub_request(:post, "https://api.openai.com/v1/chat/completions").to_return(
|
||||
status: 200,
|
||||
body: { choices: [{ message: { content: "bad" } }] }.to_json,
|
||||
)
|
||||
|
||||
triage(
|
||||
post: post,
|
||||
model: "gpt-4",
|
||||
category_id: category.id,
|
||||
system_prompt: "test %%POST%%",
|
||||
search_for_text: "bad",
|
||||
)
|
||||
|
||||
expect(post.topic.reload.category_id).to eq(category.id)
|
||||
end
|
||||
|
||||
it "can reply to topics on triage" do
|
||||
user = Fabricate(:user)
|
||||
|
||||
stub_request(:post, "https://api.openai.com/v1/chat/completions").to_return(
|
||||
status: 200,
|
||||
body: { choices: [{ message: { content: "bad" } }] }.to_json,
|
||||
)
|
||||
|
||||
triage(
|
||||
post: post,
|
||||
model: "gpt-4",
|
||||
system_prompt: "test %%POST%%",
|
||||
search_for_text: "bad",
|
||||
canned_reply: "test canned reply 123",
|
||||
canned_reply_user: user.username,
|
||||
)
|
||||
|
||||
reply = post.topic.posts.order(:post_number).last
|
||||
|
||||
expect(reply.raw).to eq("test canned reply 123")
|
||||
expect(reply.user.id).to eq(user.id)
|
||||
end
|
||||
|
||||
let(:automation) { Fabricate(:automation, script: "llm_triage", enabled: true) }
|
||||
|
||||
def add_automation_field(name, value, type: "text")
|
||||
|
@ -130,13 +33,10 @@ describe DiscourseAutomation::LlmTriage do
|
|||
add_automation_field("canned_reply", "Yo this is a reply")
|
||||
add_automation_field("canned_reply_user", user.username, type: "user")
|
||||
|
||||
stub_request(:post, "https://api.openai.com/v1/chat/completions").to_return(
|
||||
status: 200,
|
||||
body: { choices: [{ message: { content: "bad" } }] }.to_json,
|
||||
)
|
||||
|
||||
automation.running_in_background!
|
||||
automation.trigger!({ "post" => post })
|
||||
DiscourseAi::Completions::Llm.with_prepared_responses(["bad"]) do
|
||||
automation.running_in_background!
|
||||
automation.trigger!({ "post" => post })
|
||||
end
|
||||
|
||||
topic = post.topic.reload
|
||||
expect(topic.category_id).to eq(category.id)
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
# frozen_string_literal: true
|
||||
describe DiscourseAi::Automation::LlmTriage do
|
||||
fab!(:post) { Fabricate(:post) }
|
||||
|
||||
def triage(**args)
|
||||
DiscourseAi::Automation::LlmTriage.handle(**args)
|
||||
end
|
||||
|
||||
it "does nothing if it does not pass triage" do
|
||||
DiscourseAi::Completions::Llm.with_prepared_responses(["good"]) do
|
||||
triage(
|
||||
post: post,
|
||||
model: "gpt-4",
|
||||
hide_topic: true,
|
||||
system_prompt: "test %%POST%%",
|
||||
search_for_text: "bad",
|
||||
)
|
||||
end
|
||||
|
||||
expect(post.topic.reload.visible).to eq(true)
|
||||
end
|
||||
|
||||
it "can hide topics on triage with claude" do
|
||||
DiscourseAi::Completions::Llm.with_prepared_responses(["bad"]) do
|
||||
triage(
|
||||
post: post,
|
||||
model: "claude-2",
|
||||
hide_topic: true,
|
||||
system_prompt: "test %%POST%%",
|
||||
search_for_text: "bad",
|
||||
)
|
||||
end
|
||||
|
||||
expect(post.topic.reload.visible).to eq(false)
|
||||
end
|
||||
|
||||
it "can hide topics on triage with claude" do
|
||||
DiscourseAi::Completions::Llm.with_prepared_responses(["bad"]) do
|
||||
triage(
|
||||
post: post,
|
||||
model: "gpt-4",
|
||||
hide_topic: true,
|
||||
system_prompt: "test %%POST%%",
|
||||
search_for_text: "bad",
|
||||
)
|
||||
end
|
||||
|
||||
expect(post.topic.reload.visible).to eq(false)
|
||||
end
|
||||
|
||||
it "can categorize topics on triage" do
|
||||
category = Fabricate(:category)
|
||||
|
||||
DiscourseAi::Completions::Llm.with_prepared_responses(["bad"]) do
|
||||
triage(
|
||||
post: post,
|
||||
model: "gpt-4",
|
||||
category_id: category.id,
|
||||
system_prompt: "test %%POST%%",
|
||||
search_for_text: "bad",
|
||||
)
|
||||
end
|
||||
|
||||
expect(post.topic.reload.category_id).to eq(category.id)
|
||||
end
|
||||
|
||||
it "can reply to topics on triage" do
|
||||
user = Fabricate(:user)
|
||||
DiscourseAi::Completions::Llm.with_prepared_responses(["bad"]) do
|
||||
triage(
|
||||
post: post,
|
||||
model: "gpt-4",
|
||||
system_prompt: "test %%POST%%",
|
||||
search_for_text: "bad",
|
||||
canned_reply: "test canned reply 123",
|
||||
canned_reply_user: user.username,
|
||||
)
|
||||
end
|
||||
|
||||
reply = post.topic.posts.order(:post_number).last
|
||||
|
||||
expect(reply.raw).to eq("test canned reply 123")
|
||||
expect(reply.user.id).to eq(user.id)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,152 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
module DiscourseAi
|
||||
module Automation
|
||||
describe ReportContextGenerator do
|
||||
describe ".generate" do
|
||||
fab!(:private_message_post)
|
||||
fab!(:post_in_other_category) { Fabricate(:post) }
|
||||
|
||||
fab!(:category)
|
||||
fab!(:topic) { Fabricate(:topic, category: category) }
|
||||
fab!(:post_in_category) { Fabricate(:post, topic: topic) }
|
||||
fab!(:reply_in_category) { Fabricate(:post, topic: topic, reply_to_post_number: 1) }
|
||||
|
||||
fab!(:group)
|
||||
fab!(:private_category) { Fabricate(:private_category, group: group) }
|
||||
fab!(:secure_topic) do
|
||||
Fabricate(:topic, title: "category in secure category", category: private_category)
|
||||
end
|
||||
fab!(:user_in_group) { Fabricate(:user, groups: [group]) }
|
||||
fab!(:post_in_private_category) do
|
||||
Fabricate(:post, user: user_in_group, topic: secure_topic)
|
||||
end
|
||||
|
||||
fab!(:tag)
|
||||
fab!(:tag2) { Fabricate(:tag) }
|
||||
fab!(:topic_with_tag) { Fabricate(:topic, tags: [tag, tag2]) }
|
||||
fab!(:post_with_tag) { Fabricate(:post, topic: topic_with_tag) }
|
||||
|
||||
fab!(:long_post) do
|
||||
Fabricate(
|
||||
:post,
|
||||
raw: (1..100).map { |i| "testing#{i}" }.join(" "),
|
||||
topic: Fabricate(:topic, category: category),
|
||||
)
|
||||
end
|
||||
|
||||
fab!(:topic_with_likes) { Fabricate(:topic, like_count: 10) }
|
||||
|
||||
fab!(:post_with_likes) { Fabricate(:post, topic: topic_with_likes, like_count: 10) }
|
||||
|
||||
fab!(:post_with_likes2) { Fabricate(:post, topic: topic_with_likes, like_count: 5) }
|
||||
|
||||
fab!(:post_with_likes3) { Fabricate(:post, topic: topic_with_likes, like_count: 3) }
|
||||
|
||||
if defined?(::DiscourseSolved)
|
||||
it "will correctly denote solved topics" do
|
||||
topic_with_likes.custom_fields[
|
||||
::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD
|
||||
] = post_with_likes2.id
|
||||
topic_with_likes.save_custom_fields
|
||||
|
||||
context = ReportContextGenerator.generate(start_date: 1.day.ago, duration: 2.day)
|
||||
|
||||
expect(context).to include("solved: true")
|
||||
expect(context).to include("solution: true")
|
||||
end
|
||||
end
|
||||
|
||||
it "always includes info from last posts on topic" do
|
||||
context =
|
||||
ReportContextGenerator.generate(start_date: 1.day.ago, duration: 2.day, max_posts: 1)
|
||||
|
||||
expect(context).to include("...")
|
||||
expect(context).to include("post_number: 3")
|
||||
end
|
||||
|
||||
it "includes a summary" do
|
||||
context = ReportContextGenerator.generate(start_date: 1.day.ago, duration: 2.day)
|
||||
|
||||
expect(context).to include("New posts: 8")
|
||||
expect(context).to include("New topics: 5")
|
||||
end
|
||||
|
||||
it "orders so most liked are first" do
|
||||
context = ReportContextGenerator.generate(start_date: 1.day.ago, duration: 2.day)
|
||||
|
||||
regex = "topic_id: #{topic_with_likes.id}.*topic_id: #{long_post.topic.id}"
|
||||
expect(context).to match(Regexp.new(regex, Regexp::MULTILINE))
|
||||
end
|
||||
|
||||
it "allows you to prioritize groups" do
|
||||
context =
|
||||
ReportContextGenerator.generate(
|
||||
start_date: 1.day.ago,
|
||||
duration: 2.day,
|
||||
prioritized_group_ids: [group.id],
|
||||
allow_secure_categories: true,
|
||||
max_posts: 1,
|
||||
)
|
||||
|
||||
expect(context).to include(post_in_private_category.topic.title)
|
||||
expect(context).not_to include(post_in_other_category.topic.title)
|
||||
expect(context).to include(group.name)
|
||||
end
|
||||
|
||||
it "can generate context (excluding PMs)" do
|
||||
context = ReportContextGenerator.generate(start_date: 1.day.ago, duration: 2.day)
|
||||
|
||||
expect(context).to include(post_in_other_category.topic.title)
|
||||
expect(context).to include(topic.title)
|
||||
expect(context).not_to include(private_message_post.topic.title)
|
||||
expect(context).not_to include(secure_topic.title)
|
||||
end
|
||||
|
||||
it "can filter on tag" do
|
||||
context =
|
||||
ReportContextGenerator.generate(
|
||||
start_date: 1.day.ago,
|
||||
duration: 2.day,
|
||||
tags: [tag.name],
|
||||
)
|
||||
|
||||
expect(context).not_to include(post_in_other_category.topic.title)
|
||||
expect(context).not_to include(topic.title)
|
||||
expect(context).not_to include(private_message_post.topic.title)
|
||||
expect(context).not_to include(secure_topic.title)
|
||||
expect(context).to include(post_with_tag.topic.title)
|
||||
end
|
||||
|
||||
it "can optionally include secure categories" do
|
||||
context =
|
||||
ReportContextGenerator.generate(
|
||||
start_date: 1.day.ago,
|
||||
duration: 2.day,
|
||||
allow_secure_categories: true,
|
||||
)
|
||||
expect(context).to include(post_in_other_category.topic.title)
|
||||
expect(context).to include(topic.title)
|
||||
expect(context).not_to include(private_message_post.topic.title)
|
||||
expect(context).to include(secure_topic.title)
|
||||
end
|
||||
|
||||
it "can filter to a categories" do
|
||||
context =
|
||||
ReportContextGenerator.generate(
|
||||
start_date: 1.day.ago,
|
||||
duration: 2.day,
|
||||
category_ids: [category.id],
|
||||
)
|
||||
|
||||
expect(context).not_to include(post_in_other_category.topic.title)
|
||||
expect(context).to include(topic.title)
|
||||
expect(context).not_to include(private_message_post.topic.title)
|
||||
expect(context).not_to include(secure_topic.title)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,47 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
module DiscourseAi
|
||||
module Automation
|
||||
describe ReportRunner do
|
||||
fab!(:user)
|
||||
fab!(:reciever) { Fabricate(:user) }
|
||||
fab!(:post) { Fabricate(:post, user: user) }
|
||||
fab!(:group)
|
||||
fab!(:secure_category) { Fabricate(:private_category, group: group) }
|
||||
fab!(:secure_topic) { Fabricate(:topic, category: secure_category) }
|
||||
fab!(:secure_post) { Fabricate(:post, raw: "Top secret date !!!!", topic: secure_topic) }
|
||||
|
||||
describe "#run!" do
|
||||
it "generates correctly respects the params" do
|
||||
DiscourseAi::Completions::Llm.with_prepared_responses(["magical report"]) do
|
||||
ReportRunner.run!(
|
||||
sender_username: user.username,
|
||||
receivers: [reciever.username],
|
||||
title: "test report",
|
||||
model: "gpt-4",
|
||||
category_ids: nil,
|
||||
tags: nil,
|
||||
allow_secure_categories: false,
|
||||
debug_mode: true,
|
||||
sample_size: 100,
|
||||
instructions: "make a magic report",
|
||||
days: 7,
|
||||
offset: 0,
|
||||
priority_group_id: nil,
|
||||
tokens_per_post: 150,
|
||||
)
|
||||
end
|
||||
|
||||
report = Topic.where(title: "test report").first
|
||||
expect(report.ordered_posts.first.raw).to eq("magical report")
|
||||
debugging = report.ordered_posts.last.raw
|
||||
|
||||
expect(debugging).to include(post.raw)
|
||||
expect(debugging).not_to include(secure_post.raw)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue