Catalogue Detect and filter

Moderate user comments

Keep an abusive comment from being published, or put it in front of a moderator before it is.

RecommendedN2 Reviewed on

Rungs at a glance
Rung Approach Cost Latency Data Deterministic Verdict
Rule and classic algorithm N0 — Rule and classic algorithm Term list after normalisation, with a context window None <1 ms Nothing leaves Yes
Lightweight classic model N1 — Lightweight classic model Linear classifier on character n-grams Negligible ~10 ms Stays on your infrastructure Yes
Small self-hosted specialised model N2 — Small self-hosted specialised model Self-hosted toxicity classifier, with a human review band Low ~100 ms Stays on your infrastructure Yes Recommended
General-purpose LLM API N3 — General-purpose LLM API A provider's moderation endpoint High ~1 s Goes to a third party No

N0 — Rule and classic algorithm Rule and classic algorithm

Term list after normalisation, with a context window

Cost
None
Latency
<1 ms

Proof of execution : Code runs as shown

This snippet runs with its real dependencies, and its test runs on every build of the site.

Python

snippets/moderate-user-comments/n0.py
"""
Flag a comment against a term list, after normalisation, with context.

Rung N0. Deterministic, standard library only, and auditable: every decision
can be traced back to one word in a list you control.

Two things make it usable rather than merely simple.

First, normalisation. Accents and case are spellings of the same word, so they
are folded before matching. Nothing else is touched: folding further would
start inventing matches.

Second, the context window. A term list cannot decide anything on its own, so
the function returns the words around each hit. A human reads the window and
decides. A moderation tool that returns a bare boolean hides the one piece of
evidence its reviewer needs.
"""

import re
import unicodedata

# Letters and digits, in any script. Punctuation and underscores separate.
TOKEN = re.compile(r"[^\W_]+")


def normalise(text: str) -> str:
    """Fold case and strip accents, so one entry matches its spellings."""
    decomposed = unicodedata.normalize("NFKD", text.casefold())
    return "".join(c for c in decomposed if not unicodedata.combining(c))


def review(text: str, terms, window: int = 3) -> dict:
    """
    Return every listed term found in `text`, with the words around it.

    `terms` is yours: the list is policy, not code, and it belongs outside the
    function that applies it.

    `window` is a number of words on each side. Widen it when your reviewers
    keep asking what the comment was about.
    """
    listed = {normalise(t) for t in terms}
    words = TOKEN.findall(text)
    matches = []
    for position, word in enumerate(words):
        if normalise(word) in listed:
            start = max(0, position - window)
            matches.append({
                "term": normalise(word),
                "position": position,
                "context": " ".join(words[start:position + window + 1]),
            })
    return {"flagged": bool(matches), "matches": matches}

JavaScript

snippets/moderate-user-comments/n0.js
/**
 * Flag a comment against a term list, after normalisation, with context.
 *
 * Rung N0. Deterministic, no dependency, and auditable: every decision can be
 * traced back to one word in a list you control.
 *
 * Two things make it usable rather than merely simple.
 *
 * First, normalisation. Accents and case are spellings of the same word, so
 * they are folded before matching. Nothing else is touched: folding further
 * would start inventing matches.
 *
 * Second, the context window. A term list cannot decide anything on its own,
 * so the function returns the words around each hit. A human reads the window
 * and decides. A moderation tool that returns a bare boolean hides the one
 * piece of evidence its reviewer needs.
 */

// Letters and digits, in any script. Punctuation and underscores separate.
const TOKEN = /[\p{L}\p{N}]+/gu;

/** Fold case and strip accents, so one entry matches its spellings. */
export function normalise(text) {
  return text.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase();
}

/**
 * Return every listed term found in `text`, with the words around it.
 *
 * `terms` is yours: the list is policy, not code, and it belongs outside the
 * function that applies it.
 *
 * `window` is a number of words on each side. Widen it when your reviewers
 * keep asking what the comment was about.
 */
export function review(text, terms, window = 3) {
  const listed = new Set([...terms].map(normalise));
  const words = text.match(TOKEN) ?? [];
  const matches = [];
  for (const [position, word] of words.entries()) {
    if (!listed.has(normalise(word))) continue;
    matches.push({
      term: normalise(word),
      position,
      context: words.slice(Math.max(0, position - window), position + window + 1).join(' '),
    });
  }
  return { flagged: matches.length > 0, matches };
}

Risks

Data leaving
Nothing leaves
Determinism
Yes
Testability
Unit testable
Vendor dependency
None
Footprint
Negligible
Regulatory scope
  • No specific scope added: the comment never leaves your infrastructure, and the snippet flags without deciding

Breaking point

Two halves, both in the test. A spelling the list does not hold walks straight past: bl0rptard, blorp-tard, letters spaced one by one. And the list sees the word, never its use: “he called me a blorptard, please remove his comment” is flagged exactly like the insult it reports, and nothing in the result tells them apart.

When to move up a rung

Your moderators spend the day closing flags that were never abuse, or you are adding one more spelling to the list every week.

N1 — Lightweight classic model Lightweight classic model

Linear classifier on character n-grams

Cost
Negligible
Latency
~10 ms

Proof of execution : Code runs as shown

This snippet runs with its real dependencies, and its test runs on every build of the site.

Python

snippets/moderate-user-comments/n1.py
"""
Score a comment with a linear classifier trained on a labelled corpus.

Rung N1. The term list of N0 matches spellings. This matches shapes: character
n-grams, so `bl0rptard` and `blorptardd` share most of their features with the
form the model was shown, and a variant nobody added to a list still scores.

The whole model is a vector of weights over character n-grams. It is small
enough to keep beside the code, it trains while you read this docstring, and
every weight can be printed and argued about. That last property is worth more in
moderation than a point of accuracy: someone will ask why a comment was hidden.
"""

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline


def train(comments: list[str], labels: list[int]):
    """
    `labels` is 1 when the comment breaks the policy, 0 when it does not.

    Character n-grams rather than words, because abuse is spelled creatively
    and a word-level model only knows the exact tokens it was shown.

    `class_weight="balanced"` because a real moderation corpus is mostly
    ordinary comments, and an unweighted model learns to say no to everything.
    """
    model = make_pipeline(
        TfidfVectorizer(analyzer="char_wb", ngram_range=(3, 5), min_df=1),
        LogisticRegression(class_weight="balanced", max_iter=1000),
    )
    model.fit(comments, labels)
    return model


def score(model, comment: str) -> float:
    """How strongly the model reads this comment as breaking the policy."""
    return float(model.predict_proba([comment])[0][1])


def is_abusive(model, comment: str, threshold: float = 0.5) -> bool:
    """
    Return a decision, and keep the threshold in the caller's hands.

    Moderation has no neutral setting. Move it towards 1 and you silence fewer
    innocent people while letting more abuse through; move it towards 0 and you
    do the opposite. Someone has to choose, and it should not be this function.
    """
    return score(model, comment) >= threshold

JavaScript

snippets/moderate-user-comments/n1.js
/**
 * Score a comment with a linear classifier trained on a labelled corpus.
 *
 * Rung N1. The term list of N0 matches spellings. This matches shapes:
 * character n-grams, so `bl0rptard` and `blorptardd` share most of their
 * features with the form the model was shown, and a variant nobody added to a
 * list still scores.
 *
 * Written out rather than pulled from a library, because logistic regression
 * on hashed character n-grams is forty lines. The model is a vector of
 * weights: small enough to keep beside the code, trained while you read this,
 * and every weight can be printed and argued about. In moderation that last
 * property is worth more than a point of accuracy, because sooner or later
 * someone asks why their comment was hidden.
 */

// The hashing trick: no vocabulary to build, ship or keep in sync.
const BUCKETS = 1024;

/** Character n-grams of the lowercased comment, hashed into a fixed vector. */
function features(text) {
  const padded = ` ${text.toLowerCase()} `;
  const vector = new Float64Array(BUCKETS);
  for (let n = 3; n <= 5; n += 1) {
    for (let i = 0; i + n <= padded.length; i += 1) {
      let h = 2166136261;
      for (const c of padded.slice(i, i + n)) h = ((h ^ c.codePointAt(0)) * 16777619) >>> 0;
      vector[h % BUCKETS] += 1;
    }
  }
  const norm = Math.hypot(...vector);
  if (norm) for (let j = 0; j < BUCKETS; j += 1) vector[j] /= norm;
  return vector;
}

/**
 * `labels` is 1 when the comment breaks the policy, 0 when it does not.
 *
 * Each class is weighted by its rarity, because a real moderation corpus is
 * mostly ordinary comments and an unweighted model learns to allow everything.
 */
export function train(comments, labels, { epochs = 300, rate = 1 } = {}) {
  const rows = comments.map(features);
  const counts = [labels.filter((l) => l === 0).length, labels.filter((l) => l === 1).length];
  const weights = new Float64Array(BUCKETS);
  let bias = 0;
  for (let epoch = 0; epoch < epochs; epoch += 1) {
    for (let i = 0; i < rows.length; i += 1) {
      const step = (rate * labels.length) / (2 * counts[labels[i]]);
      const error = predict(rows[i], weights, bias) - labels[i];
      for (let j = 0; j < BUCKETS; j += 1) weights[j] -= step * error * rows[i][j];
      bias -= step * error;
    }
  }
  return { weights, bias };
}

function predict(vector, weights, bias) {
  let z = bias;
  for (let j = 0; j < BUCKETS; j += 1) z += weights[j] * vector[j];
  return 1 / (1 + Math.exp(-z));
}

/** How strongly the model reads this comment as breaking the policy. */
export function score(model, comment) {
  return predict(features(comment), model.weights, model.bias);
}

/**
 * Return a decision, and keep the threshold in the caller's hands.
 *
 * Moderation has no neutral setting. Move it towards 1 and you silence fewer
 * innocent people while letting more abuse through; move it towards 0 and you
 * do the opposite. Someone has to choose, and it should not be this function.
 */
export function isAbusive(model, comment, threshold = 0.5) {
  return score(model, comment) >= threshold;
}

Risks

Data leaving
Stays on your infrastructure
Determinism
Yes
Testability
Statistically testable
Vendor dependency
Library
Footprint
Low
Regulatory scope
  • Processing of user-written content on your own infrastructure
  • The training corpus keeps real comments, and the labelling decisions that go with them

Breaking point

The model learns a vocabulary, not an intent. The test shows both sides: “people like you should not be allowed to have an account here” reuses no shape it was trained on and stays under the threshold, while the report “he called me a blorptard, please remove his comment” lands above it. A larger corpus moves the boundary without changing what the model looks at, which is still the surface of the text.

When to move up a rung

The hostility you are missing is written in plain words, without a single term you could list or label.

N2 — Small self-hosted specialised model Small self-hosted specialised model Recommended

Self-hosted toxicity classifier, with a human review band

Cost
Low
Latency
~100 ms

Proof of execution : Code runs, external service simulated

This snippet runs on every build of the site, but its test replaces the external service with a local double. What is verified: the request sent, the response decoded, and the error paths. What is not: how good the model’s answer is.

Python

snippets/moderate-user-comments/n2.py
"""
Route comments with a self-hosted toxicity classifier.

Rung N2. A distilled encoder fine-tuned on a moderation corpus, running on
your own machine. It reads context the n-grams of N1 cannot, and it stays
inside your infrastructure, which matters when the text you are sending away
is the abuse one of your users just received.

What you own on this rung is not the model, it is everything around it: the
batching, the thresholds, and the answer to "what does the code do when the
model says nothing usable". The model itself is a black box with a fixed list
of labels, and the last function below is where that becomes your problem.
"""

from __future__ import annotations

MODEL_NAME = "unitary/unbiased-toxic-roberta"

# Two thresholds, not one: between "obviously fine" and "obviously not" there
# is a band that belongs to a human, and pretending otherwise is how automated
# moderation earns its reputation.
DEFAULT_THRESHOLDS = {"block": 0.9, "review": 0.6}


class ToxicityModel:
    """The real model, loaded once and kept in memory for the process."""

    def __init__(self, name: str = MODEL_NAME) -> None:
        from transformers import pipeline  # a large download, done once

        self._pipe = pipeline("text-classification", model=name, top_k=None)

    def predict(self, comments: list[str]) -> list[dict[str, float]]:
        """One label-to-score mapping per comment, in the order given."""
        return [{r["label"]: r["score"] for r in row} for row in self._pipe(comments)]


class ModerationUnavailable(Exception):
    """The model answered something no caller can act on."""


def moderate(comments, classifier=None, thresholds=None) -> list[dict]:
    """
    Decide what to do with each comment: block, send to review, or allow.

    `classifier` is injected so this can be tested without downloading the
    weights. In production it defaults to the real model above.

    The whole batch goes in one call. Feeding comments one by one is the usual
    way this rung is made slow, because a batch of a hundred is one pass
    through the model and a hundred calls are a hundred passes.
    """
    classifier = classifier or ToxicityModel()
    thresholds = thresholds or DEFAULT_THRESHOLDS
    comments = list(comments)
    scored = classifier.predict(comments)
    if len(scored) != len(comments):
        raise ModerationUnavailable("the model returned one row per comment, and did not")
    return [_decide(row, thresholds) for row in scored]


def _decide(scores, thresholds: dict[str, float]) -> dict:
    """
    Keep the strongest label, and fall back to a human when nothing is usable.

    Falling back to "allow" would be the tempting shortcut, and it would mean
    that a model failure silently publishes everything it was asked about.
    """
    usable = {label: v for label, v in (scores or {}).items() if _is_score(v)}
    if not usable:
        return {"action": "review", "label": None, "score": None}
    label, value = max(usable.items(), key=lambda item: item[1])
    if value >= thresholds["block"]:
        action = "block"
    elif value >= thresholds["review"]:
        action = "review"
    else:
        action = "allow"
    return {"action": action, "label": label, "score": value}


def _is_score(value) -> bool:
    """A number the caller can act on, rather than whatever came back."""
    return isinstance(value, (int, float)) and not isinstance(value, bool) and 0.0 <= value <= 1.0

JavaScript

snippets/moderate-user-comments/n2.js
/**
 * Route comments with a self-hosted toxicity classifier.
 *
 * Rung N2. A distilled encoder fine-tuned on a moderation corpus, running on
 * your own machine. It reads context the n-grams of N1 cannot, and it stays
 * inside your infrastructure, which matters when the text you are sending
 * away is the abuse one of your users just received.
 *
 * What you own on this rung is not the model, it is everything around it: the
 * batching, the thresholds, and the answer to "what does the code do when the
 * model says nothing usable". The model itself is a black box with a fixed
 * list of labels, and the last function below is where that becomes your
 * problem.
 */

export const MODEL_NAME = 'Xenova/toxic-bert';

// Two thresholds, not one: between "obviously fine" and "obviously not" there
// is a band that belongs to a human, and pretending otherwise is how automated
// moderation earns its reputation.
export const DEFAULT_THRESHOLDS = { block: 0.9, review: 0.6 };

export class ModerationUnavailable extends Error {}

/** The real model, loaded once and kept in memory for the process. */
export class ToxicityModel {
  static async load(name = MODEL_NAME) {
    const { pipeline } = await import('@huggingface/transformers'); // a large download, done once
    return new ToxicityModel(await pipeline('text-classification', name));
  }

  constructor(pipe) {
    this.pipe = pipe;
  }

  /** One label-to-score mapping per comment, in the order given. */
  async predict(comments) {
    const rows = await this.pipe(comments, { top_k: null });
    return rows.map((row) => Object.fromEntries(row.map((r) => [r.label, r.score])));
  }
}

/**
 * Decide what to do with each comment: block, send to review, or allow.
 *
 * `classifier` is injected so this can be tested without downloading the
 * weights. In production it defaults to the real model above.
 *
 * The whole batch goes in one call. Feeding comments one by one is the usual
 * way this rung is made slow, because a batch of a hundred is one pass through
 * the model and a hundred calls are a hundred passes.
 */
export async function moderate(comments, classifier, thresholds = DEFAULT_THRESHOLDS) {
  const model = classifier ?? (await ToxicityModel.load());
  const batch = [...comments];
  const scored = await model.predict(batch);
  if (scored.length !== batch.length) {
    throw new ModerationUnavailable('the model returned one row per comment, and did not');
  }
  return scored.map((row) => decide(row, thresholds));
}

/**
 * Keep the strongest label, and fall back to a human when nothing is usable.
 *
 * Falling back to "allow" would be the tempting shortcut, and it would mean
 * that a model failure silently publishes everything it was asked about.
 */
function decide(scores, thresholds) {
  const usable = Object.entries(scores ?? {}).filter(
    ([, v]) => typeof v === 'number' && v >= 0 && v <= 1,
  );
  if (usable.length === 0) return { action: 'review', label: null, score: null };
  const [label, score] = usable.reduce((best, row) => (row[1] > best[1] ? row : best));
  let action = 'allow';
  if (score >= thresholds.block) action = 'block';
  else if (score >= thresholds.review) action = 'review';
  return { action, label, score };
}

Risks

Data leaving
Stays on your infrastructure
Determinism
Yes
Testability
Statistically testable
Vendor dependency
Library
Footprint
Moderate
Regulatory scope
  • Processing of user-written content on your own infrastructure
  • Moderation decision taken with no human involved as soon as a comment crosses the blocking threshold
  • The taxonomy is the model author's: what it does not name does not excuse you from having to handle it

Breaking point

You inherit somebody else's taxonomy. The model scores toxicity, insult and threat; the test hands it “he lives at the corner of rue des Lilas by the way, go and say hello”, which is none of the three, scores low everywhere and goes out. The code is right; the harm just has no label.

When to move up a rung

The harm you care about has no label in any available model, and you have no labelled corpus to teach it one.

N3 — General-purpose LLM API General-purpose LLM API

A provider's moderation endpoint

Cost
High
Latency
~1 s

Proof of execution : Code runs, external service simulated

This snippet runs on every build of the site, but its test replaces the external service with a local double. What is verified: the request sent, the response decoded, and the error paths. What is not: how good the model’s answer is.

Python

snippets/moderate-user-comments/n3.py
"""
Score a comment through a provider's moderation endpoint.

Rung N3. The shortest code on the ladder to write, and the one that hands the
most away: the taxonomy, the calibration, the right to appeal, and the text of
your users' comments, which leaves your premises on every call.

The endpoint returns a number per category. Everything else here — capping the
input, retrying, refusing to act on an answer that is not the shape you asked
for — is plumbing you own, and it is where the bugs of this rung live. It is
also all your tests can reach, because the judgement itself is not testable.
"""

from __future__ import annotations

import json

CATEGORIES = ("harassment", "hate", "violence", "self_harm")

PROMPT = (
    "Rate the comment below on each moderation category. Answer with JSON\n"
    "only: an object mapping each category to a score between 0 and 1.\n"
    f"Categories: {', '.join(CATEGORIES)}\n\nComment:\n{{comment}}"
)

MAX_CHARACTERS = 4000
DEFAULT_THRESHOLDS = {"block": 0.9, "review": 0.6}


class ModerationUnavailable(Exception):
    """The provider could not be reached, or answered something unusable."""


def moderate(comment: str, client=None, *, thresholds=None, attempts: int = 3) -> dict:
    """
    Decide what to do with one comment: block, send to review, or allow.

    `client` is injected so this can be tested without a network call. In
    production it defaults to a real provider client.
    """
    if client is None:  # pragma: no cover - needs a key and a network
        from openai import OpenAI

        client = OpenAI()

    # An endpoint charges by the token, and a comment that long is a bug or an
    # attack. Refusing it is a cost control, not an optimisation.
    if len(comment) > MAX_CHARACTERS:
        raise ValueError(f"comment longer than {MAX_CHARACTERS} characters")

    thresholds = thresholds or DEFAULT_THRESHOLDS
    scores = _ask(client, comment, attempts)
    category, score = max(scores.items(), key=lambda item: item[1])
    if score >= thresholds["block"]:
        action = "block"
    elif score >= thresholds["review"]:
        action = "review"
    else:
        action = "allow"
    return {"action": action, "category": category, "score": score, "scores": scores}


def _ask(client, comment: str, attempts: int) -> dict[str, float]:
    """
    Keep the categories that came back as a number in range, and nothing else.

    A category the model invented is dropped, one it omitted is simply absent.
    An answer with none of them left is unusable, and unusable is raised rather
    than quietly turned into "allow".
    """
    last_error: Exception | None = None
    for _ in range(attempts):
        try:
            answer = client.complete(prompt=PROMPT.format(comment=comment), temperature=0)
            parsed = json.loads(answer)
            scores = {n: float(parsed[n]) for n in CATEGORIES if _is_score(parsed.get(n))}
            if scores:
                return scores
            last_error = ValueError("no category came back as a score in range")
        except Exception as error:  # noqa: BLE001 - any provider failure is retried
            last_error = error
    raise ModerationUnavailable(str(last_error))


def _is_score(value) -> bool:
    """A number the caller can act on, rather than whatever came back."""
    return isinstance(value, (int, float)) and not isinstance(value, bool) and 0.0 <= value <= 1.0

JavaScript

snippets/moderate-user-comments/n3.js
/**
 * Score a comment through a provider's moderation endpoint.
 *
 * Rung N3. The shortest code on the ladder to write, and the one that hands
 * the most away: the taxonomy, the calibration, the right to appeal, and the
 * text of your users' comments, which leaves your premises on every call.
 *
 * The endpoint returns a number per category. Everything else here — capping
 * the input, retrying, refusing to act on an answer that is not the shape you
 * asked for — is plumbing you own, and it is where the bugs of this rung live.
 * It is also all your tests can reach, because the judgement itself is not
 * testable.
 */

export const CATEGORIES = ['harassment', 'hate', 'violence', 'self_harm'];

const PROMPT = [
  'Rate the comment below on each moderation category. Answer with JSON',
  'only: an object mapping each category to a score between 0 and 1.',
  `Categories: ${CATEGORIES.join(', ')}`,
  '',
  'Comment:',
].join('\n');

export const MAX_CHARACTERS = 4000;
export const DEFAULT_THRESHOLDS = { block: 0.9, review: 0.6 };

export class ModerationUnavailable extends Error {}

/**
 * Decide what to do with one comment: block, send to review, or allow.
 *
 * @param {string} comment
 * @param {object} options
 * @param {{complete: Function}} [options.client] injected so this can be
 *   tested without a network call; defaults to a real provider client
 * @param {{block: number, review: number}} [options.thresholds]
 * @param {number} [options.attempts]
 */
export async function moderate(comment, { client, thresholds = DEFAULT_THRESHOLDS, attempts = 3 } = {}) {
  if (!client) {
    // Needs a key and a network, so it is never reached in the tests.
    const { OpenAI } = await import('openai');
    client = new OpenAI();
  }

  // An endpoint charges by the token, and a comment that long is a bug or an
  // attack. Refusing it is a cost control, not an optimisation.
  if (comment.length > MAX_CHARACTERS) {
    throw new RangeError(`comment longer than ${MAX_CHARACTERS} characters`);
  }

  const scores = await ask(client, comment, attempts);
  const [category, score] = Object.entries(scores).reduce((best, row) => (row[1] > best[1] ? row : best));
  let action = 'allow';
  if (score >= thresholds.block) action = 'block';
  else if (score >= thresholds.review) action = 'review';
  return { action, category, score, scores };
}

/**
 * Keep the categories that came back as a number in range, and nothing else.
 *
 * A category the model invented is dropped, one it omitted is simply absent.
 * An answer with none of them left is unusable, and unusable is thrown rather
 * than quietly turned into "allow".
 */
async function ask(client, comment, attempts) {
  let lastError;
  for (let i = 0; i < attempts; i += 1) {
    try {
      const answer = await client.complete({
        prompt: `${PROMPT}\n${comment}`,
        // Temperature zero, because a moderation decision that changes between
        // two identical calls cannot be explained to the person it hit.
        temperature: 0,
      });
      const parsed = JSON.parse(answer);
      const scores = Object.fromEntries(CATEGORIES.filter((n) => isScore(parsed[n])).map((n) => [n, parsed[n]]));
      if (Object.keys(scores).length > 0) return scores;
      lastError = new Error('no category came back as a score in range');
    } catch (error) {
      lastError = error;
    }
  }
  throw new ModerationUnavailable(String(lastError));
}

/** A number the caller can act on, rather than whatever came back. */
function isScore(value) {
  return typeof value === 'number' && value >= 0 && value <= 1;
}

Risks

Data leaving
Goes to a third party
Determinism
No
Testability
Hard to test
Vendor dependency
External provider
Footprint
High
Regulatory scope
  • Transfer of your users' text to a processor, including the text of people reporting the abuse they have just received
  • Processing location to be confirmed with the provider
  • Does not excuse you from being able to justify a decision the provider made on your behalf

Breaking point

The score is an opinion, not a measurement, and the code has nothing to hold it against. In the test, “the diagram is much clearer than the text” comes back scored for harassment past the blocking threshold, and the function blocks it, correctly by its own logic. No feature to inspect, no weight to print, and nothing to tell the commenter but the provider's number, which changes on the provider's schedule.

When to move up a rung

There is no rung above this one.

The verdict

RecommendedN2

N2 is the recommendation because the difficulty here is not lexical. N0 and N1 fall on the same test example, a report of an insult flagged as the insult itself, and neither a longer list nor a larger corpus gets them out of it: they read the surface of the text. The self-hosted classifier reads the context around it, keeps on your premises messages that are often somebody's account of what was done to them, and leaves you both thresholds, including the one that sends a comment to a human instead of ruling on it. N3 would hand the provider the taxonomy, the calibration, and the only explanation you could offer the author of a hidden comment; that trade only holds up when the harm you care about has a label nowhere else.

Further reading

Metadata