Skip to content

Commit 614bbf3

Browse files
authored
v1.1 (#5)
Major rework to add new detection types. * Closes #2 * Closes #4
1 parent 68516f5 commit 614bbf3

24 files changed

Lines changed: 1562 additions & 497 deletions

README.md

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1-
A Dev Platform app for detecting AI content in images.
1+
A Dev Platform app for detecting AI or other unwanted content in images.
22

3-
This app uses the Sightengine API to analyse image posts for AI content.
3+
This app uses the Sightengine API to analyse image posts. Detections supported:
4+
5+
* AI-generated images
6+
* Deepfake images
7+
* Minors in images
8+
* Poor quality (blurry) images
9+
* Offensive content (Nazi, white supremacist or terrorist imagery)
10+
* Spammy QR codes or text on an image
11+
* Drug imagery
412

513
To use, you need to sign up to [Sightengine's platform](https://sightengine.com/) and obtain an API user ID and key. You must set the API User in the app settings, and the API key via the subreddit context menu.
614

7-
For many use cases, Sightengine's free tier will be adequate, permitting 100 AI detections per day up to 400/month. For higher usage needs, Sightengine offer paid plans.
15+
For many use cases, Sightengine's free tier will be adequate, permitting 500 "operations" per day up to 2,000/month. AI and Deepfake checks use 5 "operations" each, while other checks use 1 each. For higher usage needs, Sightengine offer paid plans.
816

917
## Checking images
1018

@@ -26,6 +34,10 @@ If an image is detected as AI, a report like this will be made:
2634

2735
## Change History
2836

37+
### v1.1
38+
39+
* Add additional detection types
40+
2941
### v1.0
3042

3143
* Initial Release
@@ -34,4 +46,4 @@ If an image is detected as AI, a report like this will be made:
3446

3547
This app is open source under the BSD 3-Clause licence. The source code can be found [here](https://github.com/fsvreddit/image-moderator).
3648

37-
Interested in detections for things other than AI? Get in touch by messaging /u/fsv! The list of possibilities can be seen in Sightengine's documentation [here](https://sightengine.com/docs/models).
49+
Interested in detections for things not yet supported? Get in touch by messaging /u/fsv! The list of possibilities can be seen in Sightengine's documentation [here](https://sightengine.com/docs/models). It would be really useful if you could outline the kind of content you're looking to avoid on your subreddit.

devvit.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
11
name: image-moderator
2-
version: 1.0.0

package-lock.json

Lines changed: 506 additions & 397 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,17 @@
44
"version": "0.0.0",
55
"license": "BSD-3-Clause",
66
"type": "module",
7+
"scripts": {
8+
"test": "npm run test:unit",
9+
"test:unit": "vitest --run"
10+
},
711
"dependencies": {
8-
"@devvit/protos": "^0.11.15",
9-
"@devvit/public-api": "0.11.15",
10-
"@devvit/shared-types": "^0.11.15",
12+
"@devvit/protos": "0.11.17",
13+
"@devvit/public-api": "0.11.17",
14+
"@devvit/shared-types": "0.11.17",
15+
"@types/lodash": "^4.17.18",
1116
"@types/luxon": "^3.6.2",
17+
"lodash": "^4.17.21",
1218
"luxon": "^3.6.1"
1319
},
1420
"devDependencies": {

src/checkAIContentMenu.ts

Lines changed: 0 additions & 33 deletions
This file was deleted.

src/checkFromMenu.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { MenuItemOnPressEvent, Context } from "@devvit/public-api";
2+
import { isLinkId } from "@devvit/shared-types/tid.js";
3+
import { getSightengineResults } from "./checkSightEngineAPI.js";
4+
import { getModels, getRelevantDetectors } from "./detections/allDetections.js";
5+
6+
export async function checkPostFromMenu (event: MenuItemOnPressEvent, context: Context) {
7+
const postId = event.targetId;
8+
if (!postId || !isLinkId(postId)) {
9+
context.ui.showToast("Could not get post ID from event.");
10+
return;
11+
}
12+
13+
const post = await context.reddit.getPostById(postId);
14+
const settings = await context.settings.getAll();
15+
const detectors = getRelevantDetectors(settings, "menu");
16+
17+
if (detectors.length === 0) {
18+
context.ui.showToast("No detection models are enabled for this subreddit.");
19+
console.log("No detection models are enabled for this subreddit.");
20+
return;
21+
}
22+
23+
const models = getModels(detectors);
24+
25+
const result = await getSightengineResults(post, models, context);
26+
if (result.status === "error") {
27+
context.ui.showToast(result.message ?? "Error checking post for AI content.");
28+
console.error(`Menu: Error checking post ${postId}: ${result.message}`);
29+
return;
30+
}
31+
32+
console.log(JSON.stringify(result, null, 2));
33+
34+
const detectionResults: string[] = [];
35+
for (const Detection of detectors) {
36+
const detectionInstance = new Detection(settings);
37+
const detectionResult = detectionInstance.detectByMenu(result);
38+
if (detectionResult) {
39+
detectionResults.push(detectionResult);
40+
}
41+
}
42+
43+
context.ui.showToast(detectionResults.join(", "));
44+
console.log(`Active Check: Post ${postId} results ${detectionResults.join(", ")}.`);
45+
}
Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { postIsImage } from "./utility.js";
55
import { AppSetting } from "./settings.js";
66
import { DateTime } from "luxon";
77
import { userIsModerator } from "./moderatorChecks.js";
8+
import { getModels, getRelevantDetectors } from "./detections/allDetections.js";
89

910
function getFilterKeyForPost (postId: string) {
1011
return `filtered_post:${postId}`;
@@ -72,27 +73,36 @@ async function checkAndReportPost (postId: string, source: "PostCreate" | "PostA
7273
console.log(`${source}: User ${user.username} is a moderator. Skipping AI check.`);
7374
}
7475

75-
const result = await getSightengineResults(post, context);
76-
if (result.status === "error") {
77-
console.error(`PostCreate: Error checking post for AI content: ${result.message}`);
76+
const detectors = getRelevantDetectors(settings, "menu");
77+
78+
if (detectors.length === 0) {
7879
return;
7980
}
8081

81-
if (!result.type?.ai_generated) {
82-
console.log(`${source}: Post ${post.id} is not detected as AI content. Skipping report.`);
82+
const models = getModels(detectors);
83+
84+
const result = await getSightengineResults(post, models, context);
85+
if (result.status === "error") {
86+
console.error(`PostCreate: Error checking post for AI content: ${result.message}`);
8387
return;
8488
}
8589

86-
const aiLikelihood = Math.round(result.type.ai_generated * 100);
90+
const detectionResults: string[] = [];
91+
for (const Detection of detectors) {
92+
const detectionInstance = new Detection(settings);
93+
const detectionResult = detectionInstance.detectByMenu(result);
94+
if (detectionResult) {
95+
detectionResults.push(detectionResult);
96+
}
97+
}
8798

88-
const thresholdToReport = settings[AppSetting.ThresholdToReport] as number | undefined;
89-
if (thresholdToReport && aiLikelihood < thresholdToReport) {
90-
console.log(`${source}: AI content likelihood for ${post.id} (${aiLikelihood}%) is below threshold (${thresholdToReport}%). Skipping report.`);
99+
if (detectionResults.length === 0) {
100+
console.log(`${source}: Post ${post.id} did not match any detectors. Skipping report.`);
91101
return;
92102
}
93103

94-
console.log(`${source}: Post ${post.id} is detected as AI content with likelihood ${aiLikelihood}%. Reporting post.`);
95-
await context.reddit.report(post, { reason: `AI content likelihood: ${aiLikelihood}%` });
104+
await context.reddit.report(post, { reason: detectionResults.join(", ") });
105+
console.log(`${source}: Post ${post.id} matched: ${detectionResults.join(", ")}. Reported.`);
96106
}
97107

98108
export async function handlePostCreate (event: PostCreate, context: TriggerContext) {
@@ -102,10 +112,6 @@ export async function handlePostCreate (event: PostCreate, context: TriggerConte
102112
}
103113

104114
const settings = await context.settings.getAll();
105-
if (!settings[AppSetting.AutoCheckEnabled]) {
106-
console.log(`PostCreate: Auto check is disabled. Skipping AI check.`);
107-
return;
108-
}
109115

110116
if (settings[AppSetting.CheckAfterApproval] && event.post.spam) {
111117
console.log(`PostCreate: Post ${event.post.id} is removed or filtered. Skipping AI check.`);
@@ -132,11 +138,6 @@ export async function handlePostApprovalAction (event: ModAction, context: Trigg
132138
}
133139

134140
const settings = await context.settings.getAll();
135-
if (!settings[AppSetting.AutoCheckEnabled]) {
136-
console.log(`PostApprovalAction: Auto check is disabled. Skipping AI check.`);
137-
return;
138-
}
139-
140141
if (!settings[AppSetting.CheckAfterApproval]) {
141142
console.log(`PostApprovalAction: Check after approval is disabled. Skipping AI check.`);
142143
return;

src/checkSightEngineAPI.ts

Lines changed: 102 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { DateTime } from "luxon";
33
import { getAPIUserAndKey } from "./apiKeyManagement.js";
44
import { getImageURLFromPost } from "./utility.js";
55

6-
interface SightengineResponse {
6+
export interface SightengineResponse {
77
status: string;
88
message?: string;
99
request?: {
@@ -27,28 +27,118 @@ interface SightengineResponse {
2727
imagen?: number;
2828
ideogram?: number;
2929
};
30+
deepfake?: number;
31+
illustration?: number;
32+
photo?: number;
33+
};
34+
quality?: {
35+
score?: number;
36+
};
37+
faces?: [
38+
{
39+
x1?: number;
40+
y1?: number;
41+
x2?: number;
42+
y2?: number;
43+
features?: {
44+
left_eye?: {
45+
x: number;
46+
y: number;
47+
};
48+
right_eye?: {
49+
x: number;
50+
y: number;
51+
};
52+
nose_tip?: {
53+
x: number;
54+
y: number;
55+
};
56+
left_mouth_corner?: {
57+
x: number;
58+
y: number;
59+
};
60+
right_mouth_corner?: {
61+
x: number;
62+
y: number;
63+
};
64+
};
65+
attributes?: {
66+
minor?: number;
67+
sunglasses?: number;
68+
};
69+
},
70+
];
71+
offensive?: {
72+
nazi?: number;
73+
confederate?: number;
74+
supremacist?: number;
75+
terrorist?: number;
76+
};
77+
qr?: {
78+
personal: [];
79+
link: {
80+
type: string;
81+
match: string;
82+
category?: string;
83+
}[];
84+
social: {
85+
type: string;
86+
match: string;
87+
score?: number;
88+
}[];
89+
profanity: [];
90+
spam: {
91+
type: string;
92+
match: string;
93+
}[];
94+
};
95+
text?: {
96+
spam: {
97+
type: string;
98+
match: string;
99+
}[];
100+
};
101+
recreational_drug?: {
102+
prob?: number;
103+
classes?: {
104+
cannabis: number;
105+
cannabis_logo_only: number;
106+
cannabis_plant: number;
107+
cannabis_drug: number;
108+
recreational_drugs_not_cannabis: number;
109+
};
30110
};
31111
}
32112

113+
interface SightengineResponseWrapped {
114+
detections: string[];
115+
sightengineResponse: SightengineResponse;
116+
}
117+
33118
function getFailureResponse (message: string): SightengineResponse {
34119
return {
35120
status: "error",
36121
message,
37122
};
38123
}
39124

40-
export async function getSightengineResults (post: Post, context: TriggerContext): Promise<SightengineResponse> {
125+
export async function getSightengineResults (post: Post, detections: string[], context: TriggerContext): Promise<SightengineResponse> {
41126
const url = getImageURLFromPost(post);
42127
if (!url) {
43128
return getFailureResponse("This does not appear to be an image post.");
44129
}
45130

46-
const cachedResultKey = `sightengine_ai_check_${post.id}`;
131+
const cachedResultKey = `sightengine_check_${post.id}`;
47132
const cachedResult = await context.redis.get(cachedResultKey);
48133

49134
if (cachedResult) {
50-
console.log("Using cached result");
51-
return JSON.parse(cachedResult) as SightengineResponse;
135+
console.log("Found cached result for post:", post.id);
136+
137+
const cachedResponse = JSON.parse(cachedResult) as SightengineResponseWrapped;
138+
if (detections.every(detection => cachedResponse.detections.includes(detection))) {
139+
console.log("Using cached result");
140+
return cachedResponse.sightengineResponse;
141+
}
52142
}
53143

54144
const apiDetails = await getAPIUserAndKey(context);
@@ -62,7 +152,7 @@ export async function getSightengineResults (post: Post, context: TriggerContext
62152

63153
const params = new URLSearchParams();
64154
params.append("url", url);
65-
params.append("models", "genai");
155+
params.append("models", detections.join(","));
66156
params.append("api_user", apiDetails.apiUser);
67157
params.append("api_secret", apiDetails.apiKey);
68158

@@ -80,7 +170,12 @@ export async function getSightengineResults (post: Post, context: TriggerContext
80170
return getFailureResponse("Error checking post for AI content.");
81171
}
82172

83-
await context.redis.set(cachedResultKey, JSON.stringify(result), { expiration: DateTime.now().plus({ months: 1 }).toJSDate() });
173+
const wrappedResult: SightengineResponseWrapped = {
174+
detections,
175+
sightengineResponse: result,
176+
};
177+
178+
await context.redis.set(cachedResultKey, JSON.stringify(wrappedResult), { expiration: DateTime.now().plus({ months: 1 }).toJSDate() });
84179

85180
return result;
86181
}

0 commit comments

Comments
 (0)