FEATURE: Display the Watched Words that caused a post to be flagged. (#31435)

When a post is flagged due to matching watched words, it can be difficult to know what you're looking for, particularly if you have a lot of watched words built up over a long period of time.

This change stores the list of matched words, and later displays them in the review queue, listing which Watched Words were responsible for the flag. Because watched words can change, this is recorded at the time the post is flagged. For posts that were flagged prior to this feature landing, it tries to guess the relevant words based on the current Watched Words set.
This commit is contained in:
Gary Pendergast
2025-03-04 17:22:12 +11:00
committed by GitHub
parent b329eac79a
commit 9abeff460c
10 changed files with 137 additions and 8 deletions

View File

@ -23,6 +23,9 @@
{{#if rs.reason}} {{#if rs.reason}}
<div class="reviewable-score-reason">{{html-safe rs.reason}}</div> <div class="reviewable-score-reason">{{html-safe rs.reason}}</div>
{{/if}} {{/if}}
{{#if rs.context}}
<div class="reviewable-score-context">{{html-safe rs.context}}</div>
{{/if}}
{{#if rs.reviewable_conversation}} {{#if rs.reviewable_conversation}}
<div class="reviewable-conversation"> <div class="reviewable-conversation">

View File

@ -49,12 +49,15 @@ module Jobs
if !post.user&.staff? && !post.user&.staged? if !post.user&.staff? && !post.user&.staged?
s = post.raw s = post.raw
s << " #{post.topic.title}" if post.post_number == 1 s << " #{post.topic.title}" if post.post_number == 1
if !args[:bypass_bump] && WordWatcher.new(s).should_flag? word_watcher = WordWatcher.new(s)
if !args[:bypass_bump] && word_watcher.should_flag?
words = word_watcher.word_matches_for_action?(:flag, all_matches: true)
PostActionCreator.create( PostActionCreator.create(
Discourse.system_user, Discourse.system_user,
post, post,
:inappropriate, :inappropriate,
reason: :watched_word, reason: :watched_word,
context: words.join(","),
) )
end end
end end

View File

@ -199,7 +199,8 @@ class Reviewable < ActiveRecord::Base
created_at: nil, created_at: nil,
take_action: false, take_action: false,
meta_topic_id: nil, meta_topic_id: nil,
force_review: false force_review: false,
context: nil
) )
type_bonus = PostActionType.where(id: reviewable_score_type).pluck(:score_bonus)[0] || 0 type_bonus = PostActionType.where(id: reviewable_score_type).pluck(:score_bonus)[0] || 0
take_action_bonus = take_action ? 5.0 : 0.0 take_action_bonus = take_action ? 5.0 : 0.0
@ -216,6 +217,7 @@ class Reviewable < ActiveRecord::Base
meta_topic_id: meta_topic_id, meta_topic_id: meta_topic_id,
take_action_bonus: take_action_bonus, take_action_bonus: take_action_bonus,
created_at: created_at || Time.zone.now, created_at: created_at || Time.zone.now,
context: context,
) )
rs.reason = reason.to_s if reason rs.reason = reason.to_s if reason
rs.save! rs.save!

View File

@ -117,6 +117,7 @@ end
# updated_at :datetime not null # updated_at :datetime not null
# reason :string # reason :string
# user_accuracy_bonus :float default(0.0), not null # user_accuracy_bonus :float default(0.0), not null
# context :string
# #
# Indexes # Indexes
# #

View File

@ -42,7 +42,12 @@ class ReviewableScoreSerializer < ApplicationSerializer
if link_text if link_text
link = build_link_for(object.reason, link_text) link = build_link_for(object.reason, link_text)
if object.reason == "watched_word"
text = watched_word_reason(link)
else
text = I18n.t("reviewables.reasons.#{object.reason}", link: link, default: object.reason) text = I18n.t("reviewables.reasons.#{object.reason}", link: link, default: object.reason)
end
else else
text = I18n.t("reviewables.reasons.#{object.reason}", default: object.reason) text = I18n.t("reviewables.reasons.#{object.reason}", default: object.reason)
end end
@ -69,6 +74,35 @@ class ReviewableScoreSerializer < ApplicationSerializer
private private
def watched_word_reason(link)
if object.context.nil?
# If the words weren't recorded, try to guess them based on current settings.
words =
WordWatcher.new(object.reviewable.post.raw).word_matches_for_action?(
:flag,
all_matches: true,
)
else
words = object.context.split(",")
end
if words.nil?
text =
I18n.t("reviewables.reasons.no_context.watched_word", link: link, default: "watched_word")
else
text =
I18n.t(
"reviewables.reasons.watched_word",
link: link,
words: words.join(", "),
count: words.length,
default: "watched_word",
)
end
text
end
def url_for(reason, text) def url_for(reason, text)
case reason case reason
when "watched_word" when "watched_word"

View File

@ -5584,7 +5584,9 @@ en:
new_topics_unless_trust_level: "Users at low trust levels must have topics approved by staff. See %{link}." new_topics_unless_trust_level: "Users at low trust levels must have topics approved by staff. See %{link}."
fast_typer: "New user typed their first post suspiciously fast, suspected bot or spammer behavior. See %{link}." fast_typer: "New user typed their first post suspiciously fast, suspected bot or spammer behavior. See %{link}."
auto_silence_regex: "New user whose first post matches the %{link} setting." auto_silence_regex: "New user whose first post matches the %{link} setting."
watched_word: "This post included a Watched Word. See your %{link}." watched_word:
one: "This post included a Watched Word, the flagged word is: %{words}. Check the %{link} for more."
other: "This post included Watched Words, the flagged words are: %{words}. Check the %{link} for more."
staged: "New topics and posts for staged users must be approved by staff. See %{link}." staged: "New topics and posts for staged users must be approved by staff. See %{link}."
category: "Posts in this category require manual approval by staff. See the %{link}." category: "Posts in this category require manual approval by staff. See the %{link}."
must_approve_users: "All new users must be approved by staff. See %{link}." must_approve_users: "All new users must be approved by staff. See %{link}."
@ -5595,8 +5597,10 @@ en:
contains_media: "This post includes embedded media. See %{link}." contains_media: "This post includes embedded media. See %{link}."
queued_by_staff: "A staff member thinks this post needs review. It'll remain hidden until then." queued_by_staff: "A staff member thinks this post needs review. It'll remain hidden until then."
links: links:
watched_word: list of watched words watched_word: watched word settings
category: category settings category: category settings
no_context:
watched_word: "This post included a Watched Word. Check the %{link} for more."
actions: actions:
agree: agree:

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
class AddContextToReviewableScores < ActiveRecord::Migration[7.2]
def change
add_column :reviewable_scores, :context, :string
end
end

View File

@ -14,7 +14,8 @@ class PostActionCreator
message: nil, message: nil,
created_at: nil, created_at: nil,
reason: nil, reason: nil,
silent: false silent: false,
context: nil
) )
new( new(
created_by, created_by,
@ -24,6 +25,7 @@ class PostActionCreator
created_at: created_at, created_at: created_at,
reason: reason, reason: reason,
silent: silent, silent: silent,
context: context,
).perform ).perform
end end
@ -50,7 +52,8 @@ class PostActionCreator
created_at: nil, created_at: nil,
queue_for_review: false, queue_for_review: false,
reason: nil, reason: nil,
silent: false silent: false,
context: nil
) )
@created_by = created_by @created_by = created_by
@created_at = created_at || Time.zone.now @created_at = created_at || Time.zone.now
@ -73,6 +76,8 @@ class PostActionCreator
@reason = "queued_by_staff" if reason.nil? && @queue_for_review @reason = "queued_by_staff" if reason.nil? && @queue_for_review
@silent = silent @silent = silent
@context = context
end end
def post_can_act? def post_can_act?
@ -403,6 +408,7 @@ class PostActionCreator
meta_topic_id: @meta_post&.topic_id, meta_topic_id: @meta_post&.topic_id,
reason: @reason, reason: @reason,
force_review: trusted_spam_flagger?, force_review: trusted_spam_flagger?,
context: @context,
) )
end end

View File

@ -12,6 +12,7 @@ RSpec.describe WatchedWord do
Fabricate(:watched_word, action: WatchedWord.actions[:require_approval]) Fabricate(:watched_word, action: WatchedWord.actions[:require_approval])
end end
let(:flag_word) { Fabricate(:watched_word, action: WatchedWord.actions[:flag]) } let(:flag_word) { Fabricate(:watched_word, action: WatchedWord.actions[:flag]) }
let(:another_flag_word) { Fabricate(:watched_word, action: WatchedWord.actions[:flag]) }
let(:block_word) { Fabricate(:watched_word, action: WatchedWord.actions[:block]) } let(:block_word) { Fabricate(:watched_word, action: WatchedWord.actions[:block]) }
let(:another_block_word) { Fabricate(:watched_word, action: WatchedWord.actions[:block]) } let(:another_block_word) { Fabricate(:watched_word, action: WatchedWord.actions[:block]) }
@ -232,7 +233,43 @@ RSpec.describe WatchedWord do
).to eq(true) ).to eq(true)
reviewable = ReviewableFlaggedPost.where(target: post) reviewable = ReviewableFlaggedPost.where(target: post)
expect(reviewable).to be_present expect(reviewable).to be_present
expect(ReviewableScore.where(reviewable: reviewable, reason: "watched_word")).to be_present expect(
ReviewableScore.where(
reviewable: reviewable,
reason: "watched_word",
context: flag_word.word,
),
).to be_present
end
it "should flag the post if it contains multiple flagged words" do
topic = Fabricate(:topic, user: tl2_user)
post =
Fabricate(
:post,
raw:
"I said.... #{flag_word.word} and #{another_flag_word.word} and #{flag_word.word} again",
topic: topic,
user: tl2_user,
)
expect { Jobs::ProcessPost.new.execute(post_id: post.id) }.to change { PostAction.count }.by(
1,
)
expect(
PostAction.where(
post_id: post.id,
post_action_type_id: PostActionType.types[:inappropriate],
).exists?,
).to eq(true)
reviewable = ReviewableFlaggedPost.where(target: post)
expect(reviewable).to be_present
expect(
ReviewableScore.where(
reviewable: reviewable,
reason: "watched_word",
context: "#{flag_word.word},#{another_flag_word.word}",
),
).to be_present
end end
it "should look at the title too" do it "should look at the title too" do

View File

@ -63,6 +63,39 @@ RSpec.describe ReviewableScoreSerializer do
expect(serialized.reason).to eq(custom) expect(serialized.reason).to eq(custom)
end end
end end
context "with watched words" do
let(:link) do
"<a href=\"#{Discourse.base_url}/admin/customize/watched_words\">#{I18n.t("reviewables.reasons.links.watched_word")}</a>"
end
it "tries to guess the watched words if they weren't recorded at the time of flagging" do
reviewable.target =
Fabricate(:post, raw: "I'm a post with some bad words like 'bad' and 'words'.")
score = serialized_score("watched_word")
Fabricate(:watched_word, action: WatchedWord.actions[:flag], word: "bad")
Fabricate(:watched_word, action: WatchedWord.actions[:flag], word: "words")
expect(score.reason).to include("bad, words")
end
it "uses the no-context message if the post has no watched words" do
reviewable.target = Fabricate(:post, raw: "This post contains no bad words.")
score = serialized_score("watched_word")
Fabricate(:watched_word, action: WatchedWord.actions[:flag], word: "superbad")
expect(score.reason).to eq(
I18n.t(
"reviewables.reasons.no_context.watched_word",
link: link,
default: "watched_word",
),
)
end
end
end end
describe "#setting_name_for_reason" do describe "#setting_name_for_reason" do