mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 02:19:55 +08:00
Previously, AI triage only collapsed a duplicate reviewable when a spam flag followed an existing review flag; the reverse order still left a separate `ReviewablePost` alongside the `ReviewableFlaggedPost`. This change adds `review_reviewable_for` so a later review flag reuses an existing flagged reviewable, ensuring moderators see a single review item regardless of which triage rule runs first. Follow up to https://github.com/discourse/discourse/pull/41043
351 lines
12 KiB
Ruby
Vendored
351 lines
12 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
#
|
|
module DiscourseAi
|
|
module Automation
|
|
module LlmTriage
|
|
def self.flagged_by_another_triage_rule?(post)
|
|
triage_score_types = [ReviewableScore.types[:spam], ReviewableScore.types[:needs_approval]]
|
|
|
|
ReviewableScore
|
|
.pending
|
|
.where(user: Discourse.system_user, reviewable_score_type: triage_score_types)
|
|
.joins(:reviewable)
|
|
.where(reviewables: { target: post })
|
|
.exists?
|
|
end
|
|
|
|
def self.flagged_by_tool?(raw_context)
|
|
return false if raw_context.blank?
|
|
|
|
raw_context.any? do |entry|
|
|
next false if !entry.is_a?(Array)
|
|
next false if entry[2] != "tool" || entry[3] != "flag_post"
|
|
|
|
payload = entry[0]
|
|
status =
|
|
case payload
|
|
when String
|
|
JSON.parse(payload).with_indifferent_access[:status]
|
|
when Hash
|
|
payload.with_indifferent_access[:status]
|
|
end
|
|
|
|
status == "flagged"
|
|
rescue JSON::ParserError, TypeError
|
|
false
|
|
end
|
|
end
|
|
|
|
def self.promote_post_reviewable_to_flagged!(post)
|
|
reviewable = ReviewablePost.pending.find_by(target: post)
|
|
return if reviewable.blank?
|
|
return if ReviewableFlaggedPost.exists?(target: post)
|
|
|
|
reviewable.update!(
|
|
type: ReviewableFlaggedPost.name,
|
|
potential_spam: true,
|
|
reviewable_by_moderator: true,
|
|
payload: {
|
|
targets_topic: false,
|
|
},
|
|
)
|
|
rescue ActiveRecord::RecordNotUnique
|
|
raise if !ReviewableFlaggedPost.exists?(target: post)
|
|
end
|
|
private_class_method :promote_post_reviewable_to_flagged!
|
|
|
|
def self.review_reviewable_for(post)
|
|
# If a spam triage rule already flagged this post, reuse that reviewable
|
|
# instead of creating a separate ReviewablePost. This mirrors
|
|
# `promote_post_reviewable_to_flagged!` for the opposite ordering, so
|
|
# moderators always see a single review item regardless of which rule
|
|
# ran first.
|
|
ReviewableFlaggedPost.pending.find_by(target: post) ||
|
|
ReviewablePost.needs_review!(
|
|
target: post,
|
|
created_by: Discourse.system_user,
|
|
reviewable_by_moderator: true,
|
|
)
|
|
end
|
|
private_class_method :review_reviewable_for
|
|
|
|
def self.handle(
|
|
post:,
|
|
triage_agent_id:,
|
|
search_for_text:,
|
|
category_id: nil,
|
|
tags: nil,
|
|
canned_reply: nil,
|
|
canned_reply_user: nil,
|
|
hide_topic: nil,
|
|
flag_post: nil,
|
|
flag_type: nil,
|
|
automation: nil,
|
|
max_post_tokens: nil,
|
|
stop_sequences: nil,
|
|
whisper: nil,
|
|
reply_agent_id: nil,
|
|
max_output_tokens: nil,
|
|
action: nil,
|
|
notify_author_pm: nil,
|
|
notify_author_pm_user: nil,
|
|
notify_author_pm_message: nil
|
|
)
|
|
if category_id.blank? && tags.blank? && canned_reply.blank? && hide_topic.blank? &&
|
|
flag_post.blank? && reply_agent_id.blank?
|
|
raise ArgumentError, "llm_triage: no action specified!"
|
|
end
|
|
|
|
if action == :edit && category_id.blank? && tags.blank? && flag_post.blank? &&
|
|
hide_topic.blank?
|
|
return
|
|
end
|
|
|
|
triage_agent = AiAgent.find(triage_agent_id)
|
|
model_id = triage_agent.default_llm_id || SiteSetting.ai_default_llm_model
|
|
return if model_id.blank?
|
|
model = LlmModel.find(model_id)
|
|
|
|
bot =
|
|
DiscourseAi::Agents::Bot.as(
|
|
Discourse.system_user,
|
|
agent: triage_agent.class_instance.new,
|
|
model: model,
|
|
)
|
|
|
|
input = "title: #{post.topic.title}\n#{post.raw}"
|
|
|
|
input =
|
|
model.tokenizer_class.truncate(
|
|
input,
|
|
max_post_tokens,
|
|
strict: SiteSetting.ai_strict_token_counting,
|
|
) if max_post_tokens.present?
|
|
|
|
upload_ids =
|
|
DiscourseAi::Completions::PromptMessagesBuilder.filtered_upload_ids_for_prompt(
|
|
post.upload_ids,
|
|
include_image_uploads: triage_agent.vision_enabled,
|
|
include_document_uploads: model.allowed_attachment_types.present?,
|
|
allowed_attachment_types: model.allowed_attachment_types,
|
|
guardian: Guardian.new(post.user),
|
|
)
|
|
|
|
if upload_ids.present?
|
|
input = [input]
|
|
input.concat(upload_ids.map { |upload_id| { upload_id: upload_id } })
|
|
end
|
|
|
|
bot_ctx =
|
|
DiscourseAi::Agents::BotContext.new(
|
|
user: Discourse.system_user,
|
|
post: post,
|
|
skip_show_thinking: true,
|
|
feature_name: "llm_triage",
|
|
feature_context: {
|
|
automation_id: automation&.id,
|
|
automation_name: automation&.name,
|
|
base_path: Discourse.base_path,
|
|
action: action,
|
|
notify_author_pm: notify_author_pm,
|
|
notify_author_pm_user: notify_author_pm_user,
|
|
notify_author_pm_message: notify_author_pm_message,
|
|
},
|
|
messages: [{ type: :user, content: input }],
|
|
)
|
|
|
|
result = nil
|
|
|
|
llm_args = {
|
|
max_tokens: max_output_tokens,
|
|
stop_sequences: stop_sequences,
|
|
feature_context: {
|
|
automation_id: automation&.id,
|
|
automation_name: automation&.name,
|
|
},
|
|
}
|
|
|
|
result = +""
|
|
raw_context =
|
|
bot.reply(bot_ctx, llm_args: llm_args) do |partial, _, type|
|
|
result << partial if type.blank?
|
|
end
|
|
|
|
flagged_by_tool = flagged_by_tool?(raw_context)
|
|
|
|
matched = result.present? && result.downcase.include?(search_for_text.downcase)
|
|
matched ||= flagged_by_tool
|
|
|
|
if matched
|
|
user = User.find_by_username(canned_reply_user) if canned_reply_user.present?
|
|
original_user = user
|
|
user = user || Discourse.system_user
|
|
if reply_agent_id.present? && action != :edit
|
|
begin
|
|
DiscourseAi::AiBot::Playground.reply_to_post(
|
|
post: post,
|
|
agent_id: reply_agent_id,
|
|
whisper: whisper,
|
|
user: original_user,
|
|
attributed_user: Discourse.system_user,
|
|
)
|
|
rescue StandardError => e
|
|
Discourse.warn_exception(
|
|
e,
|
|
message: "Error responding to: #{post&.url} in LlmTriage.handle",
|
|
)
|
|
raise e if Rails.env.test?
|
|
end
|
|
elsif canned_reply.present? && action != :edit
|
|
post_type = whisper ? Post.types[:whisper] : Post.types[:regular]
|
|
PostCreator.create!(
|
|
user,
|
|
topic_id: post.topic_id,
|
|
raw: canned_reply,
|
|
reply_to_post_number: post.post_number,
|
|
skip_validations: true,
|
|
post_type: post_type,
|
|
)
|
|
end
|
|
|
|
changes = {}
|
|
changes[:category_id] = category_id if category_id.present?
|
|
if SiteSetting.tagging_enabled? && tags.present?
|
|
changes[:tags] = post.topic.tags.map(&:name).concat(tags)
|
|
end
|
|
|
|
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
|
|
|
|
if flag_post
|
|
# Check if another triage rule already created a reviewable for this post.
|
|
# We'll later use it to avoid sending multiple PMs to the user.
|
|
# We are doing this now before we create another flag.
|
|
already_flagged = flagged_by_another_triage_rule?(post)
|
|
|
|
score_reason =
|
|
DiscourseAi::Automation.flag_post_reason(
|
|
reason: result,
|
|
automation_id: automation&.id.to_s,
|
|
automation_name: automation&.name,
|
|
)
|
|
|
|
if !flagged_by_tool
|
|
if flag_type == :spam || flag_type == :spam_silence
|
|
spam_score_reason =
|
|
DiscourseAi::Automation.spam_score_reason(
|
|
automation_id: automation&.id.to_s,
|
|
automation_name: automation&.name,
|
|
)
|
|
|
|
spam_post_action_message =
|
|
DiscourseAi::Automation.spam_post_action_message(
|
|
reason: result,
|
|
automation_id: automation&.id.to_s,
|
|
automation_name: automation&.name,
|
|
)
|
|
|
|
promote_post_reviewable_to_flagged!(post)
|
|
|
|
result =
|
|
PostActionCreator.new(
|
|
Discourse.system_user,
|
|
post,
|
|
PostActionType.types[:spam],
|
|
message: spam_post_action_message,
|
|
reason: spam_score_reason,
|
|
queue_for_review: true,
|
|
).perform
|
|
|
|
if flag_type == :spam_silence
|
|
if result.success?
|
|
SpamRule::AutoSilence.new(post.user, post).silence_user
|
|
else
|
|
Rails.logger.warn(
|
|
"llm_triage: unable to flag post as spam, post action failed for #{post.id} with error: '#{result.errors.full_messages.join(",").truncate(3000)}'",
|
|
)
|
|
end
|
|
end
|
|
else
|
|
reviewable = review_reviewable_for(post)
|
|
|
|
reviewable.add_score(
|
|
Discourse.system_user,
|
|
ReviewableScore.types[:needs_approval],
|
|
reason: score_reason,
|
|
force_review: true,
|
|
)
|
|
|
|
# We cannot do this through the PostActionCreator because hiding a post is reserved for auto action flags.
|
|
# Those flags are off_topic, inappropriate, and spam. We want a more generic type for triage, so none of those
|
|
# fit here.
|
|
if flag_type == :review_hide
|
|
post.hide!(PostActionType.types[:notify_moderators])
|
|
elsif flag_type == :review_delete || flag_type == :review_delete_silence
|
|
# Soft-delete the post so it is hidden from users until a moderator handles it in review.
|
|
PostDestroyer.new(Discourse.system_user, post, context: "llm_triage").destroy
|
|
|
|
if flag_type == :review_delete_silence
|
|
UserSilencer.silence(
|
|
post.user,
|
|
Discourse.system_user,
|
|
message: :silenced_by_staff,
|
|
post_id: post.id,
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if notify_author_pm && action != :edit && !already_flagged
|
|
begin
|
|
pm_sender =
|
|
if notify_author_pm_user.present?
|
|
User.find_by_username(notify_author_pm_user)
|
|
else
|
|
nil
|
|
end
|
|
pm_sender ||= Discourse.system_user
|
|
|
|
subject =
|
|
I18n.t("discourse_automation.scriptables.llm_triage.notify_author_pm.subject")
|
|
|
|
default_body =
|
|
I18n.t(
|
|
"discourse_automation.scriptables.llm_triage.notify_author_pm.body",
|
|
username: post.user.username,
|
|
topic_title: post.topic.title,
|
|
post_url: post.url,
|
|
)
|
|
|
|
body = notify_author_pm_message.presence || default_body
|
|
|
|
PostCreator.create!(
|
|
pm_sender,
|
|
title: subject,
|
|
raw: body,
|
|
archetype: Archetype.private_message,
|
|
target_usernames: post.user.username,
|
|
skip_validations: true,
|
|
)
|
|
rescue StandardError => e
|
|
Discourse.warn_exception(
|
|
e,
|
|
message:
|
|
"Error sending PM notification for triage on: #{post&.url} in LlmTriage.handle",
|
|
)
|
|
raise e if Rails.env.test?
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|