diff --git a/README.md b/README.md index 8ccc876..80c4e32 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ The server listens on `http://localhost:8080` by default. - `TRUST_PROXY`: set to `true` when running behind a trusted reverse proxy so upload limits use `X-Forwarded-For` Uploads accept PNG and JPEG images. The server rejects files over 5 MB, any image edge over `6000px`, and images over 20 million pixels. Accepted uploads are decoded, metadata-stripped, resized so the longest edge is at most `1600px`, and stored as WebP. -Upload caps are 5 per hour per IP, 10 per day per IP, and 100 globally per day. AI-approved uploads publish immediately; ambiguous uploads are queued for the secret admin review page; likely illegal uploads are rejected immediately. +Upload caps are 5 per hour per IP, 10 per day per IP, and 100 globally per day. Strong AI-approved uploads publish immediately; ambiguous or low-quality uploads are queued for the secret admin review page; likely illegal uploads are rejected immediately. Files are stored under sharded date/hash paths: @@ -56,7 +56,7 @@ Set `SITE_URL` in production so canonical and API discovery URLs use the public - `GET /media/`: returns the normalized WebP image for one approved meme. - `POST /api/memes`: submits one PNG/JPEG upload as `multipart/form-data` with a file field named `meme`. -API uploads use the exact same path as the browser form: persistent IP upload quotas, file size checks, image dimension and pixel checks, metadata-stripping WebP normalization, AI moderation, and admin-review queueing. A published upload returns `201`; a queued upload returns `202`; moderation rejection returns `422`. +API uploads use the exact same path as the browser form: persistent IP upload quotas, file size checks, image dimension and pixel checks, metadata-stripping WebP normalization, AI moderation, and admin-review queueing. A published upload returns `201`; a queued upload returns `202` with `moderationReason`; moderation rejection returns `422` with `moderationReason`. Public API reads are rate-limited per IP and return `429` with `Retry-After` when exceeded. List and metadata responses include `RateLimit-*` headers and a `rateLimit` object. diff --git a/meme-api.skill.md b/meme-api.skill.md index 5df7263..36ee175 100644 --- a/meme-api.skill.md +++ b/meme-api.skill.md @@ -46,7 +46,7 @@ The API intentionally uses the same upload path as the human browser form: Successful upload responses: - `201` means the meme was approved and published immediately. -- `202` means the meme was accepted and queued for admin review. +- `202` means the meme was accepted and queued for admin review. The body includes `moderationReason`. Rejected or invalid upload responses: @@ -54,7 +54,7 @@ Rejected or invalid upload responses: - `411` when upload size is missing. - `413` when request, file, dimension, or pixel limits are exceeded. - `415` when the image is not PNG or JPEG. -- `422` when moderation rejects the upload. The body includes `moderationScore`. +- `422` when moderation rejects the upload. The body includes `moderationScore` and `moderationReason`. - `429` when upload quotas are exhausted. Do not attempt to bypass moderation, normalization, or limits. There is no privileged public upload endpoint. diff --git a/openapi.json b/openapi.json index ba6100d..99c581f 100644 --- a/openapi.json +++ b/openapi.json @@ -443,6 +443,8 @@ "required": [ "meme", "quota", + "moderationScore", + "moderationReason", "message" ], "properties": { @@ -452,6 +454,14 @@ "quota": { "$ref": "#/components/schemas/UploadQuota" }, + "moderationScore": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "moderationReason": { + "type": "string" + }, "message": { "type": "string", "enum": [ @@ -518,7 +528,8 @@ "type": "object", "required": [ "error", - "moderationScore" + "moderationScore", + "moderationReason" ], "properties": { "error": { @@ -528,6 +539,9 @@ "type": "integer", "minimum": 0, "maximum": 100 + }, + "moderationReason": { + "type": "string" } } } diff --git a/public/assets/app.js b/public/assets/app.js index 3918066..9ba2122 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -72,7 +72,7 @@ uploadForm.addEventListener('submit', async (event) => { } else { uploadForm.reset(); fileLabel.textContent = 'SELECT PNG / JPEG'; - formStatus.textContent = `${payload.message || 'QUEUED_FOR_REVIEW'} MEME_CONSENSUS_SCORE: ${payload.meme?.moderationScore ?? '--'}`; + formStatus.textContent = payload.message || 'Upload queued for admin review.'; } } catch (error) { formStatus.textContent = error.message.toUpperCase(); diff --git a/server.js b/server.js index 15674b2..b830650 100644 --- a/server.js +++ b/server.js @@ -192,6 +192,8 @@ const server = http.createServer(async (req, res) => { return sendJson(res, meme.status === 'approved' ? 201 : 202, { meme, quota, + moderationScore: meme.moderationScore, + moderationReason: meme.moderationReason, message: meme.status === 'approved' ? 'Upload approved.' : 'Upload queued for admin review.' }); } @@ -376,7 +378,8 @@ async function submitMeme(req) { error.statusCode = 422; error.payload = { error: error.message, - moderationScore: moderation.score + moderationScore: moderation.score, + moderationReason: moderation.reason }; throw error; } diff --git a/src/moderation.js b/src/moderation.js index 4105d3a..92cce87 100644 --- a/src/moderation.js +++ b/src/moderation.js @@ -1,4 +1,5 @@ const DEFAULT_MODEL = process.env.OPENAI_MODERATION_MODEL || 'gpt-4o-mini'; +const AUTO_APPROVE_MIN_SCORE = 80; export async function moderateImage({ buffer, mime }) { if (!process.env.OPENAI_API_KEY) { @@ -64,11 +65,15 @@ function moderationPrompt() { 'Do not reject merely because a meme is rude, critical, weird, darkly humorous, or adult in tone.', 'Reject only if the image appears illegal or likely illegal to host or distribute, including child sexual content, sexual content involving minors, explicit non-consensual sexual content, bestiality, credible illegal activity instructions, terrorist or extremist recruitment, doxxing/private identity documents, or explicit threats that appear actionable.', 'If legality or age is ambiguous, choose pending.', - 'Assign MEME_CONSENSUS_SCORE from 0-100: higher means it is clearly a meme/reaction/roast/remix and suitable for this site; lower means random photo, spam, ad, QR scam, screenshot dump, or unclear.', + 'Quality matters. Do not auto-approve low-quality, generic, or AI-slop memes; send them to pending for admin review.', + 'Low-quality memes usually have generic stock meme templates with large caption text, captions that could fit dozens of unrelated images, jokes that rely almost entirely on text, images that add little or nothing, literal descriptions instead of punchlines, excessive text that obscures the image, no clear comedic payoff, or a humorless generated feel.', + 'High-quality memes usually have strong visual composition, a joke contained in or strengthened by the image, visual storytelling, an unexpected punchline, original artwork or a meaningful remix, and a caption/image pairing where the joke would lose meaning if the image changed.', + 'Before deciding, ask: is there an actual punchline; could the exact same caption work on 50 unrelated images; does the image meaningfully contribute; is the text overwhelming the artwork; does it feel like a specific joke someone wanted to make; would users share it because it is clever rather than merely because it exists?', + 'Assign MEME_CONSENSUS_SCORE from 0-100: higher means it is legal, clearly a meme/reaction/roast/remix, visually meaningful, has a real punchline, and is suitable for this site; lower means random photo, spam, ad, QR scam, screenshot dump, unclear, generic template caption, text-only joke, weak payoff, or AI-slop.', 'Return only compact JSON with keys: decision, score, reason.', 'decision must be one of: approved, pending, rejected.', - 'Use approved only when it appears legal and score is at least 65.', - 'Use pending for ambiguity, uncertainty, low meme relevance, or anything that needs human review.', + `Use approved only when it appears legal and score is at least ${AUTO_APPROVE_MIN_SCORE}.`, + 'Use pending for ambiguity, uncertainty, low meme relevance, weak quality, generic/slop memes, or anything that needs human review.', 'Use rejected only for likely illegal content.' ].join('\n'); } @@ -95,7 +100,7 @@ function normalizeDecision(raw) { ? raw.reason.trim().slice(0, 300) : 'No moderation reason supplied.'; - if (decision === 'approved' && score < 65) { + if (decision === 'approved' && score < AUTO_APPROVE_MIN_SCORE) { return { status: 'pending', score, reason: `${reason} Low MEME_CONSENSUS_SCORE queued for review.` }; } return { status: decision, score, reason }; diff --git a/tests/moderation.test.js b/tests/moderation.test.js new file mode 100644 index 0000000..68cc7c9 --- /dev/null +++ b/tests/moderation.test.js @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { moderateImage } from '../src/moderation.js'; + +test('queues uploads when AI moderation is not configured', async () => { + const originalApiKey = process.env.OPENAI_API_KEY; + delete process.env.OPENAI_API_KEY; + + try { + const moderation = await moderateImage({ + buffer: Buffer.from('image'), + mime: 'image/webp' + }); + + assert.equal(moderation.status, 'pending'); + assert.equal(moderation.score, 50); + assert.match(moderation.reason, /not configured/); + } finally { + if (originalApiKey === undefined) { + delete process.env.OPENAI_API_KEY; + } else { + process.env.OPENAI_API_KEY = originalApiKey; + } + } +}); + +test('queues model approvals below the auto-publish quality threshold', async () => { + const originalApiKey = process.env.OPENAI_API_KEY; + const originalFetch = globalThis.fetch; + process.env.OPENAI_API_KEY = 'test-key'; + + globalThis.fetch = async (_url, options) => { + const body = JSON.parse(options.body); + const prompt = body.input[0].content.find((item) => item.type === 'input_text').text; + + assert.match(prompt, /could the exact same caption work on 50 unrelated images/); + assert.match(prompt, /Use approved only when it appears legal and score is at least 80/); + + return { + ok: true, + json: async () => ({ + output_text: JSON.stringify({ + decision: 'approved', + score: 79, + reason: 'Specific joke, but not strong enough.' + }) + }) + }; + }; + + try { + const moderation = await moderateImage({ + buffer: Buffer.from('image'), + mime: 'image/webp' + }); + + assert.equal(moderation.status, 'pending'); + assert.equal(moderation.score, 79); + assert.match(moderation.reason, /Low MEME_CONSENSUS_SCORE queued for review/); + } finally { + globalThis.fetch = originalFetch; + if (originalApiKey === undefined) { + delete process.env.OPENAI_API_KEY; + } else { + process.env.OPENAI_API_KEY = originalApiKey; + } + } +});