Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit 5702520

Browse files
committed
working on specs
1 parent c22c951 commit 5702520

File tree

5 files changed

+224
-41
lines changed

5 files changed

+224
-41
lines changed

app/models/ai_spam_log.rb

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
# frozen_string_literal: true
22
class AiSpamLog < ActiveRecord::Base
3+
belongs_to :post
4+
belongs_to :llm_model
5+
belongs_to :ai_api_audit_log
36
end
47

58
# == Schema Information
69
#
710
# Table name: ai_spam_logs
811
#
9-
# id :bigint not null, primary key
10-
# post_id :bigint not null
11-
# llm_model_id :bigint not null
12-
# last_ai_api_audit_log_id :bigint not null
13-
# scan_count :integer default(1), not null
14-
# is_spam :boolean not null
15-
# last_scan_payload :text default(""), not null
16-
# created_at :datetime not null
17-
# updated_at :datetime not null
12+
# id :bigint not null, primary key
13+
# post_id :bigint not null
14+
# llm_model_id :bigint not null
15+
# ai_api_audit_log_id :bigint not null
16+
# is_spam :boolean not null
17+
# payload :text default(""), not null
18+
# created_at :datetime not null
19+
# updated_at :datetime not null
1820
#

config/locales/server.en.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,8 @@ en:
251251
other_content_in_pm: "Personal messages containing posts from other people cannot be shared publicly"
252252
failed_to_share: "Failed to share the conversation"
253253
conversation_deleted: "Conversation share deleted successfully"
254+
spam_detection:
255+
flag_reason: "Flagged as spam by Discourse AI"
254256
ai_bot:
255257
default_pm_prefix: "[Untitled AI bot PM]"
256258
personas:

db/migrate/20241206051223_add_ai_spam_logs.rb renamed to db/migrate/20241206051224_add_ai_spam_logs.rb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ def change
44
create_table :ai_spam_logs do |t|
55
t.bigint :post_id, null: false
66
t.bigint :llm_model_id, null: false
7-
t.bigint :last_ai_api_audit_log_id, null: false
8-
t.integer :scan_count, null: false, default: 1
7+
t.bigint :ai_api_audit_log_id
98
t.boolean :is_spam, null: false
10-
t.text :last_scan_payload, null: false, default: "", limit: 20_000
9+
t.text :payload, null: false, default: "", limit: 20_000
1110
t.timestamps
1211
end
12+
13+
add_index :ai_spam_logs, :post_id
1314
end
1415
end

lib/ai_moderation/spam_scanner.rb

Lines changed: 26 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,20 @@ def self.enabled?
4444
def self.should_scan_post?(post)
4545
return false if !post.present?
4646
return false if post.user.trust_level > TrustLevel[1]
47-
return false if post.user.post_count > POSTS_TO_SCAN
4847
return false if post.topic.private_message?
48+
if Post
49+
.where(user_id: post.user_id)
50+
.joins(:topic)
51+
.where(topic: { archetype: Archetype.default })
52+
.limit(4)
53+
.count > 3
54+
return false
55+
end
4956
true
5057
end
5158

5259
def self.scanned_max_times?(post)
53-
AiSpamLog.where(post_id: post.id).sum(:scan_count) >= 3
60+
AiSpamLog.where(post_id: post.id).count >= 3
5461
end
5562

5663
def self.significant_change?(previous_version, current_version)
@@ -92,13 +99,26 @@ def self.perform_scan(post)
9299
},
93100
)&.strip
94101

95-
is_spam = result.present? && result.downcase.include?("spam")
102+
is_spam = (result.present? && result.downcase.include?("spam"))
103+
104+
log = AiApiAuditLog.order(id: :desc).where(feature_name: "spam_detection").first
96105

97-
create_log_entry(post, settings.llm_model, result, is_spam)
106+
AiSpamLog.transaction do
107+
AiSpamLog.create!(
108+
post: post,
109+
llm_model: settings.llm_model,
110+
ai_api_audit_log: log,
111+
is_spam: is_spam,
112+
payload: context,
113+
)
114+
handle_spam(post, result) if is_spam
115+
end
98116

99-
handle_spam(post, result) if is_spam
100117
rescue StandardError => e
101-
Rails.logger.error("Error in SpamScanner for post #{post.id}: #{e.message}")
118+
if Rails.env.test?
119+
raise e
120+
end
121+
Discourse.warn_exception(e, message: "Error in SpamScanner for post #{post.id}")
102122
end
103123
end
104124

@@ -134,12 +154,6 @@ def self.build_context(post)
134154

135155
context << "\nPost Content:"
136156
context << post.raw
137-
138-
if post.linked_urls.present?
139-
context << "\nLinks in post:"
140-
context << post.linked_urls.join(", ")
141-
end
142-
143157
context.join("\n")
144158
end
145159

@@ -179,23 +193,6 @@ def self.build_system_prompt(custom_instructions)
179193
base_prompt
180194
end
181195

182-
def self.create_log_entry(post, llm_model, result, is_spam)
183-
log = AiSpamLog.find_or_initialize_by(post_id: post.id)
184-
185-
if log.new_record?
186-
log.llm_model = llm_model
187-
log.is_spam = is_spam
188-
log.scan_count = 1
189-
else
190-
log.scan_count += 1
191-
end
192-
193-
last_audit = DiscourseAi::ApiAuditLog.last
194-
log.last_ai_api_audit_log_id = last_audit.id if last_audit
195-
196-
log.save!
197-
end
198-
199196
def self.handle_spam(post, result)
200197
SpamRule::AutoSilence.new(post.user, post).silence_user
201198

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
RSpec.describe DiscourseAi::AiModeration::SpamScanner do
6+
fab!(:user) { Fabricate(:user, trust_level: TrustLevel[0]) }
7+
fab!(:topic) { Fabricate(:topic) }
8+
fab!(:post) { Fabricate(:post, user: user, topic: topic) }
9+
fab!(:llm_model) { Fabricate(:llm_model) }
10+
fab!(:spam_setting) do
11+
AiModerationSetting.create!(
12+
setting_type: :spam,
13+
llm_model: llm_model,
14+
data: { custom_instructions: "test instructions" }
15+
)
16+
end
17+
18+
before do
19+
SiteSetting.discourse_ai_enabled = true
20+
SiteSetting.ai_spam_detection_enabled = true
21+
end
22+
23+
describe ".enabled?" do
24+
it "returns true when both settings are enabled" do
25+
expect(described_class.enabled?).to eq(true)
26+
end
27+
28+
it "returns false when discourse_ai is disabled" do
29+
SiteSetting.discourse_ai_enabled = false
30+
expect(described_class.enabled?).to eq(false)
31+
end
32+
33+
it "returns false when spam detection is disabled" do
34+
SiteSetting.ai_spam_detection_enabled = false
35+
expect(described_class.enabled?).to eq(false)
36+
end
37+
end
38+
39+
describe ".should_scan_post?" do
40+
it "returns true for new users' posts" do
41+
expect(described_class.should_scan_post?(post)).to eq(true)
42+
end
43+
44+
it "returns false for trusted users" do
45+
post.user.trust_level = TrustLevel[2]
46+
expect(described_class.should_scan_post?(post)).to eq(false)
47+
end
48+
49+
it "returns false for users with many public posts" do
50+
Fabricate(:post, user: user, topic: topic)
51+
Fabricate(:post, user: user, topic: topic)
52+
expect(described_class.should_scan_post?(post)).to eq(true)
53+
54+
pm = Fabricate(:private_message_topic, user: user)
55+
Fabricate(:post, user: user, topic: pm)
56+
57+
expect(described_class.should_scan_post?(post)).to eq(true)
58+
59+
topic = Fabricate(:topic, user: user)
60+
Fabricate(:post, user: user, topic: topic)
61+
62+
expect(described_class.should_scan_post?(post)).to eq(false)
63+
end
64+
65+
it "returns false for private messages" do
66+
pm_topic = Fabricate(:private_message_topic)
67+
pm_post = Fabricate(:post, topic: pm_topic, user: user)
68+
expect(described_class.should_scan_post?(pm_post)).to eq(false)
69+
end
70+
71+
it "returns false for nil posts" do
72+
expect(described_class.should_scan_post?(nil)).to eq(false)
73+
end
74+
end
75+
76+
describe ".scanned_max_times?" do
77+
it "returns true when post has been scanned 3 times" do
78+
3.times do
79+
AiSpamLog.create!(
80+
post: post,
81+
llm_model: llm_model,
82+
ai_api_audit_log_id: 1,
83+
is_spam: false
84+
)
85+
end
86+
87+
expect(described_class.scanned_max_times?(post)).to eq(true)
88+
end
89+
90+
it "returns false for posts scanned less than 3 times" do
91+
expect(described_class.scanned_max_times?(post)).to eq(false)
92+
end
93+
end
94+
95+
describe ".significant_change?" do
96+
it "returns true for first edits" do
97+
expect(described_class.significant_change?(nil, "new content")).to eq(true)
98+
end
99+
100+
it "returns true for significant changes" do
101+
old_version = "This is a test post"
102+
new_version = "This is a completely different post with new content"
103+
expect(described_class.significant_change?(old_version, new_version)).to eq(true)
104+
end
105+
106+
it "returns false for minor changes" do
107+
old_version = "This is a test post"
108+
new_version = "This is a test Post" # Only capitalization change
109+
expect(described_class.significant_change?(old_version, new_version)).to eq(false)
110+
end
111+
end
112+
113+
describe ".new_post" do
114+
it "enqueues spam scan job for eligible posts" do
115+
Jobs.expects(:enqueue).with(:ai_spam_scan, post_id: post.id)
116+
described_class.new_post(post)
117+
end
118+
119+
it "doesn't enqueue jobs when disabled" do
120+
SiteSetting.ai_spam_detection_enabled = false
121+
Jobs.expects(:enqueue).never
122+
described_class.new_post(post)
123+
end
124+
end
125+
126+
describe ".edited_post" do
127+
it "enqueues spam scan job for eligible edited posts" do
128+
PostRevision.create!(
129+
post: post,
130+
modifications: { raw: ["old content", "completely new content"] }
131+
)
132+
133+
Jobs.expects(:enqueue).with(:ai_spam_scan, post_id: post.id)
134+
described_class.edited_post(post)
135+
end
136+
137+
it "schedules delayed job when edited too soon after last scan" do
138+
AiSpamLog.create!(
139+
post: post,
140+
llm_model: llm_model,
141+
ai_api_audit_log_id: 1,
142+
is_spam: false,
143+
created_at: 5.minutes.ago
144+
)
145+
146+
Jobs.expects(:enqueue_in)
147+
described_class.edited_post(post)
148+
end
149+
end
150+
151+
describe "integration test" do
152+
fab!(:llm_model) { Fabricate(:llm_model) }
153+
let(:api_audit_log) { Fabricate(:api_audit_log) }
154+
155+
before do
156+
Jobs.run_immediately!
157+
end
158+
159+
it "Correctly handles spam scanning" do
160+
# we need a proper audit log so
161+
prompt = nil
162+
DiscourseAi::Completions::Llm.with_prepared_responses(["spam"]) do |_,_,_prompts|
163+
described_class.new_post(post)
164+
prompt = _prompts.first
165+
end
166+
167+
content = prompt.messages[1][:content]
168+
expect(content).to include(post.topic.title)
169+
expect(content).to include(post.raw)
170+
171+
log = AiSpamLog.find_by(post: post)
172+
173+
expect(log.payload).to eq(content)
174+
expect(log.is_spam).to eq(true)
175+
expect(post.user.reload.silenced_till).to be_present
176+
177+
# hmm maybe it should be?
178+
#expect(post.topic.visible).to eq(false)
179+
end
180+
end
181+
end

0 commit comments

Comments
 (0)