Examples · Web Development
Moderating comments on your blog
Fifteen comments on a blog post go to a model, which gives each one a decision from a fixed list (publish, spam, offensive, needs_human) and a short reason. Your code checks that every comment got exactly one decision, sends links and doubts to a queue for a person, and never deletes anything. Two models, compared.
Beginner · 25 min
The idea
A blog with open comments gets three kinds of visitors: readers, spammers and the occasional troll. Reading every comment by hand is slow; letting a program delete comments on its own is worse, because one day it deletes a real one.
So the model sorts, and a person decides what is not clear:
- the model gives each comment a decision from a fixed list, with a reason;
- your code checks that every comment got exactly one decision;
- your rules send to a person what the model should not decide alone;
- nothing is deleted: every comment is saved with its status, and the doubtful ones wait in
queue.json.
What you need
Node.js 22 or newer, a key and the openai package, as in Your first API call in JavaScript.
The comments
Sara, a student in Busto Arsizio, wrote a post called “CSS Grid in ten minutes”. These are its first fifteen comments. Save them as comments.json, and decide yourself what you would publish.
[
{"id": 1, "author": "Giulia R.", "text": "Thanks! The part about grid-template-areas finally made sense to me."},
{"id": 2, "author": "Matteo", "text": "On my phone the layout breaks below 400px. Am I doing something wrong, or do I need a media query?"},
{"id": 3, "author": "TopFollowers", "text": "Great post!!! Get 10,000 real followers in 24 hours at http://fast-followers.example/promo"},
{"id": 4, "author": "Anna", "text": "Earn 500 euro a day from home, no experience needed. Click here: www.easy-money.example"},
{"id": 5, "author": "Davide B.", "text": "Nice article. I wrote about the same topic on my blog: https://davide-codes.example/css-grid. I'd love your opinion."},
{"id": 6, "author": "anonymous", "text": "Worst tutorial I've ever read. You clearly have no idea what you're doing, idiot."},
{"id": 7, "author": "Kevin", "text": "Matteo, your question is stupid. Go back to school before you comment here."},
{"id": 8, "author": "Federica", "text": "Honestly, this is very basic stuff. If you need a tutorial for this, maybe web development is not for you."},
{"id": 9, "author": "Nonno Gino", "text": "Bel lavor, fioeu! Hoo pruaa anca mi cont el telefonin e el va propi ben."},
{"id": 10, "author": "Luca T.", "text": "Does anyone know if the pizzeria in Via Milano in Legnano is open on Mondays?"},
{"id": 11, "author": "Chiara", "text": "Grazie! Ho usato il tuo esempio per il progetto di scuola e il prof l'ha apprezzato."},
{"id": 12, "author": "Paolo M.", "text": "For browser support, MDN has a good table: https://developer.mozilla.org/en-US/docs/Web/CSS/grid"},
{"id": 13, "author": "Simone", "text": "What a load of cr*p. Two hours wasted and my grid still doesn't work."},
{"id": 14, "author": "Elena", "text": "Small typo in the second example: grid-template-colums. With the missing n it works perfectly."},
{"id": 15, "author": "Bella_22", "text": "Ciao! Foto esclusive sul mio profilo, scrivimi qui: t.me/bella22_privato"}
]
Comment 9 is in Lombard dialect, from a grandfather: “Nice work, kid! I tried it too on my phone and it works really well.”
The program
Save it as moderate.mjs, next to comments.json:
import { readFileSync, writeFileSync } from "node:fs";
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.ai.itsincom.org/v1",
apiKey: process.env.API_KEY,
});
const model = process.argv[2] ?? "qwen3.8-27b";
const comments = JSON.parse(readFileSync("comments.json", "utf8"));
const DECISIONS = ["publish", "spam", "offensive", "needs_human"];
const schema = {
type: "object",
properties: {
decisions: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "integer", enum: comments.map((c) => c.id) }, // only comments that exist
decision: { type: "string", enum: DECISIONS },
reason: { type: "string" },
},
required: ["id", "decision", "reason"],
},
},
},
required: ["decisions"],
};
const answer = await client.chat.completions.create({
model,
temperature: 0,
messages: [
{
role: "system",
content: "You moderate the comments of a blog post about CSS Grid. The blog's rules: be kind, " +
"no ads, stay on topic. For each comment choose: publish (it follows the rules), spam (ads, " +
"scams, links to sell something), offensive (insults a person), needs_human (you are not " +
"sure, or a person should decide). Give one decision per comment, with a reason of at most " +
"15 words.",
},
{ role: "user", content: JSON.stringify(comments) },
],
response_format: { type: "json_schema", json_schema: { name: "moderation", schema } },
});
if (answer.choices[0].finish_reason !== "stop") {
throw new Error(`The answer was cut off (${answer.choices[0].finish_reason}): run it again.`);
}
const decisions = JSON.parse(answer.choices[0].message.content).decisions;
const LINK = /https?:\/\/|www\.|t\.me\//i;
const result = comments.map((comment) => {
const mine = decisions.filter((d) => d.id === comment.id);
// Your rules: no decision, or two, goes to a person; so does a link the model wants to publish.
if (mine.length !== 1) {
return { ...comment, decision: null, status: "needs_human", reason: `${mine.length} decisions from the model` };
}
const { decision, reason } = mine[0];
if (decision === "publish" && LINK.test(comment.text)) {
return { ...comment, decision, status: "needs_human", reason: `has a link; the model said: ${reason}` };
}
return { ...comment, decision, status: decision, reason };
});
// Nothing is deleted: every comment is saved with its status, and a person reads the queue.
writeFileSync(`moderated-${model}.json`, JSON.stringify(result, null, 2));
const queue = result.filter((c) => c.status === "needs_human");
writeFileSync("queue.json", JSON.stringify(queue, null, 2));
for (const c of result) {
console.log(`${String(c.id).padStart(2)} ${c.status.padEnd(11)} ${c.reason}`);
}
console.log(`${queue.length} comments in queue.json for a person; nothing deleted.`);
Run it with node moderate.mjs. To try another model, name it: node moderate.mjs gemma-4-26b. In our runs Qwen3.8 took 17 to 25 seconds, because it thinks before it answers; Gemma took 4 to 5.
What you should see
A run of qwen3.8-27b:
1 publish Kind, on-topic thanks about grid-template-areas.
2 publish Genuine on-topic question about responsive grid layout.
3 spam Selling followers with a promotional link.
4 spam Classic get-rich-quick scam with external link.
5 needs_human has a link; the model said: On-topic, kind, shares a related resource without selling.
6 offensive Direct personal insult calling the author an idiot.
7 offensive Insults Matteo personally and tells him to go back to school.
8 needs_human Condescending and unkind, but not a direct personal insult.
9 publish Kind, on-topic feedback in dialect; no ads or insults.
10 needs_human Completely off-topic (pizzeria hours), but not spam or offensive.
11 publish Kind, on-topic thanks in Italian; no issues.
12 needs_human has a link; the model said: Helpful, on-topic reference to MDN browser support table.
13 needs_human Frustrated and unkind, but directed at content, not a person.
14 publish Kind, on-topic typo correction for grid-template-columns.
15 spam Advertising exclusive photos with a Telegram link.
5 comments in queue.json for a person; nothing deleted.
moderated-qwen3.8-27b.json has all fifteen comments, each with the model's decision and its final status. queue.json has the five a person must read:
{
"id": 5,
"author": "Davide B.",
"text": "Nice article. I wrote about the same topic on my blog: https://davide-codes.example/css-grid. I'd love your opinion.",
"decision": "publish",
"status": "needs_human",
"reason": "has a link; the model said: On-topic, kind, shares a related resource without selling."
},
Two models compared
To see where two models disagree, save this as compare.mjs. It reads two result files and prints the comments with different decisions:
import { readFileSync } from "node:fs";
const [a, b] = process.argv.slice(2).map((file) => JSON.parse(readFileSync(file, "utf8")));
let same = 0;
for (let i = 0; i < a.length; i++) {
if (a[i].decision === b[i].decision) {
same++;
} else {
console.log(`${String(a[i].id).padStart(2)} ${a[i].decision} / ${b[i].decision}: «${a[i].text}»`);
}
}
console.log(`Same decision on ${same} of ${a.length} comments.`);
node compare.mjs moderated-qwen3.8-27b.json moderated-gemma-4-26b.json
5 publish / needs_human: «Nice article. I wrote about the same topic on my blog: https://davide-codes.example/css-grid. I'd love your opinion.»
8 needs_human / offensive: «Honestly, this is very basic stuff. If you need a tutorial for this, maybe web development is not for you.»
9 publish / needs_human: «Bel lavor, fioeu! Hoo pruaa anca mi cont el telefonin e el va propi ben.»
10 needs_human / spam: «Does anyone know if the pizzeria in Via Milano in Legnano is open on Mondays?»
Same decision on 11 of 15 comments.
We ran each model three times. Run by run, the two models gave the same decision on 12, 12 and 11 of the 15 comments. Each model alone was steadier: 14 or 15 of 15 from one of its runs to the next.
They always agreed on the clear cases: the two readers' thanks, the question, the typo, the three ads, the two insults, the MDN link, and “cr*p”, which both always sent to a person. They disagreed on four comments, the hard ones:
| Comment | qwen3.8-27b |
gemma-4-26b |
|---|---|---|
| 5, a link to the author's own blog | publish 2 of 3, needs_human 1 of 3 | needs_human 3 of 3 |
| 8, “maybe web development is not for you” | needs_human 3 of 3 | offensive 3 of 3 |
| 9, the dialect | publish 3 of 3 | needs_human 2 of 3, publish 1 of 3 |
| 10, the pizzeria | needs_human 3 of 3 | spam 3 of 3 |
What the rules did
- The pizzeria is not spam. Gemma called Luca's question spam in every run: off topic, so it went where the ads go. Qwen3.8 sent it to a person. The question is harmless, and wrong for this post: that is a decision for Sara. Because nothing is deleted, she can find it in
moderated-gemma-4-26b.jsonand publish it with one change. A comment that had been deleted could not be fixed. - The link rule closed a disagreement. On comment 5, Qwen3.8 said publish and Gemma said needs_human; after the rule, both ended up in the queue. The rule also sent the MDN link to the queue, which both models wanted to publish. That costs Sara a few seconds, and it is the price of a simple rule: a model does not open links, and does not know where they go.
- Links come in many shapes. Comment 4 has no
http, and comment 15 is a Telegram address,t.me/.... The regular expression knows three shapes; real spam will find a fourth. - The check on the ids never fired in our runs: each model gave exactly one decision per comment. The
enumin the schema stops an id that does not exist; it cannot stop a comment from being forgotten or answered twice. Your code can, so it does: the comment goes to the queue. - Doubt goes to a person. Federica's comment is rude to readers, not an insult to one person. Gemma said offensive, Qwen3.8 said needs_human. The blog's rules do not settle it, so neither model is wrong: that is the case
needs_humanexists for.
Good to know
- Comments are personal data. A name and an opinion are about a person: send the model only what it needs to decide. Does it need the author's name? The first exercise finds out.
- One call for many comments is cheaper and faster than one call per comment, but a long list can be cut off. Check
finish_reason, as the program does, and send the comments in groups of 20 or 30. - Hidden is not deleted.
spamandoffensivehide a comment; they do not remove it. Keep the hidden comments for a while, so a reader who writes “where is my comment?” can get an answer.
Exercise
- Remove the author's name from what you send:
comments.map(({ id, text }) => ({ id, text })). Do the decisions change? - Add
off_topictoDECISIONS, and say in the system message what it means. Where does Gemma put the pizzeria now? - Add a rule of your own: a comment from an author who already has an approved comment can be published with a link. Where do you keep the approved authors?
- Write a comment that both models publish, and that you would not. What does that tell you about the queue?
