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}}
<div class="reviewable-score-reason">{{html-safe rs.reason}}</div>
{{/if}}
{{#if rs.context}}
<div class="reviewable-score-context">{{html-safe rs.context}}</div>
{{/if}}
{{#if rs.reviewable_conversation}}
<div class="reviewable-conversation">

View File

@ -49,12 +49,15 @@ module Jobs
if !post.user&.staff? && !post.user&.staged?
s = post.raw
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(
Discourse.system_user,
post,
:inappropriate,
reason: :watched_word,
context: words.join(","),
)
end
end

View File

@ -199,7 +199,8 @@ class Reviewable < ActiveRecord::Base
created_at: nil,
take_action: false,
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
take_action_bonus = take_action ? 5.0 : 0.0
@ -216,6 +217,7 @@ class Reviewable < ActiveRecord::Base
meta_topic_id: meta_topic_id,
take_action_bonus: take_action_bonus,
created_at: created_at || Time.zone.now,
context: context,
)
rs.reason = reason.to_s if reason
rs.save!

View File

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

View File

@ -42,7 +42,12 @@ class ReviewableScoreSerializer < ApplicationSerializer
if 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)
end
else
text = I18n.t("reviewables.reasons.#{object.reason}", default: object.reason)
end
@ -69,6 +74,35 @@ class ReviewableScoreSerializer < ApplicationSerializer
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)
case reason
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}."
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."
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}."
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}."
@ -5595,8 +5597,10 @@ en:
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."
links:
watched_word: list of watched words
watched_word: watched word settings
category: category settings
no_context:
watched_word: "This post included a Watched Word. Check the %{link} for more."
actions:
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,
created_at: nil,
reason: nil,
silent: false
silent: false,
context: nil
)
new(
created_by,
@ -24,6 +25,7 @@ class PostActionCreator
created_at: created_at,
reason: reason,
silent: silent,
context: context,
).perform
end
@ -50,7 +52,8 @@ class PostActionCreator
created_at: nil,
queue_for_review: false,
reason: nil,
silent: false
silent: false,
context: nil
)
@created_by = created_by
@created_at = created_at || Time.zone.now
@ -73,6 +76,8 @@ class PostActionCreator
@reason = "queued_by_staff" if reason.nil? && @queue_for_review
@silent = silent
@context = context
end
def post_can_act?
@ -403,6 +408,7 @@ class PostActionCreator
meta_topic_id: @meta_post&.topic_id,
reason: @reason,
force_review: trusted_spam_flagger?,
context: @context,
)
end

View File

@ -12,6 +12,7 @@ RSpec.describe WatchedWord do
Fabricate(:watched_word, action: WatchedWord.actions[:require_approval])
end
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(:another_block_word) { Fabricate(:watched_word, action: WatchedWord.actions[:block]) }
@ -232,7 +233,43 @@ RSpec.describe WatchedWord do
).to eq(true)
reviewable = ReviewableFlaggedPost.where(target: post)
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
it "should look at the title too" do

View File

@ -63,6 +63,39 @@ RSpec.describe ReviewableScoreSerializer do
expect(serialized.reason).to eq(custom)
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
describe "#setting_name_for_reason" do