Skip to content
Fondazione ITS INCOM

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:

  1. the model gives each comment a decision from a fixed list, with a reason;
  2. your code checks that every comment got exactly one decision;
  3. your rules send to a person what the model should not decide alone;
  4. 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

Good to know

Exercise

All examples