Modérer les commentaires des utilisateurs

Empêcher qu’un commentaire injurieux soit publié, ou le faire remonter à un modérateur avant qu’il le soit.

De l’IA, mais un petit modèle chez vous

Peu coûteuxRapideVos données restent chez vous

Le verdict

N2, parce que c’est le premier niveau qui arrive déjà entraîné sur les commentaires étiquetés d’un défi public de détection de toxicité, là où N1 exige un corpus à vous, et qu’il garde chez vous des messages qui sont souvent le récit d’un préjudice subi. Il ne règle pas la difficulté du contexte : N0 et N1 marquent tous deux, dans leurs tests, le signalement d’une insulte comme l’insulte elle-même, et la fiche du modèle prévient qu’il note toxique un commentaire qui contient des mots d’insulte, quelle que soit l’intention. C’est pour cela que l’extrait tient deux seuils et envoie la bande du milieu à un humain au lieu de trancher. Deux réserves, et elles sont dans la fiche : les deux seuils sont des valeurs d’exemple, à étalonner sur un échantillon de vos commentaires relus à la main, et le couple modèle-étiquettes se choisit avec la langue — celui de l’extrait ne note que l’anglais, et sur un forum français il faut prendre le couple multilingue, qui couvre le français mais ne rend qu’une seule étiquette, donc un nombre sans raison à donner au relecteur. N3 rendrait au fournisseur le sens de chaque catégorie, l’étalonnage et la seule explication que vous pourriez donner à l’auteur d’un commentaire masqué ; l’échange ne se défend que si le préjudice qui vous occupe n’a d’étiquette nulle part — et, s’il s’agit des nuisances usuelles, le fournisseur pris en exemple publie un point d’accès de modération dédié que sa documentation donne pour gratuit : personne ne paie un modèle de conversation pour ce qu’il rend sans compteur.

Classifieur de toxicité auto-hébergé (anglais, ou multilingue à une étiquette), avec bande de relecture humaine

De l’IA, mais un petit modèle chez vous

Petit modèle spécialisé auto-hébergéN2Peu coûteuxRapide

Python

snippets/moderate-user-comments/n2.pyTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
"""
Orienter les commentaires avec un classifieur de toxicité auto-hébergé.

Niveau N2. Un encodeur affiné sur les commentaires étiquetés d'un défi public
de détection de toxicité, qui tourne sur votre machine. Il arrive entraîné,
là où N1 vous oblige à rassembler un corpus, et il reste dans votre
infrastructure, ce qui compte quand le texte qu'on enverrait ailleurs est
l'injure qu'un de vos utilisateurs vient de recevoir.

Le modèle et les étiquettes qui ont le droit de décider vont ensemble, et
c'est la langue de vos commentaires qui choisit le couple. Celui d'anglais
note sept nuisances distinctes, ce qu'on montre à un relecteur ; le
multilingue, dont la fiche liste le français parmi quatorze langues, n'en
note qu'une. Brancher le modèle anglais sur un forum français rend des
nombres, pas des erreurs : c'est l'échec que ce niveau vous tendra le plus
volontiers.

Ce qui vous appartient à ce niveau n'est pas le modèle, c'est tout ce qui
l'entoure : le regroupement en lots, les seuils, les étiquettes qui ont le
droit de décider, et la réponse à « que fait le code quand le modèle ne dit
rien d'exploitable ». Le modèle lui-même est
une boîte noire avec une liste d'étiquettes figée, et la dernière fonction
ci-dessous est l'endroit où cela devient votre problème.
"""

from __future__ import annotations

import functools

# English only: its corpus is Civil Comments. Seven labels name a harm; the
# model also scores which identities a comment mentions (male, muslim, black…),
# and those say who is mentioned, not what is done to them, so they are left out
# and never decide anything here.
ENGLISH = ("unitary/unbiased-toxic-roberta",
           ("toxicity", "severe_toxicity", "obscene", "identity_attack",
            "insult", "threat", "sexual_explicit"))

# Fourteen languages, French among them, and one label for all of them: its
# card lists the languages and publishes a validation score per language. What
# you lose is the breakdown — "insult" or "threat" is what a moderator reads,
# "toxic 0.87" is what is left. Its licence is not the other one's either: read
# both before you ship.
MULTILINGUAL = ("gravitee-io/distilbert-multilingual-toxicity-classifier", ("toxic",))

# The examples on this page are English, so this file is set to the English
# pair. A French forum swaps the line, and loses the breakdown with it.
MODEL_NAME, HARM_LABELS = ENGLISH

# Two thresholds, not one: between "obviously fine" and "obviously not" there
# is a band that belongs to a human. These two numbers are examples, not
# defaults to leave alone: calibrate them on a sample of your own comments that
# somebody has read by hand.
DEFAULT_THRESHOLDS = {"block": 0.9, "review": 0.6}


class ToxicityModel:
    """The real model. A large download: load it once, with `default_model`."""

    def __init__(self, name: str = MODEL_NAME) -> None:
        from transformers import pipeline

        # Truncated to the length the model reads, as Transformers.js does:
        # whatever sits past it in a very long comment is not scored.
        self._pipe = pipeline("text-classification", model=name, top_k=None, truncation=True)

    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)]


@functools.cache
def default_model() -> ToxicityModel:
    """The real model, loaded on first use and kept for the process."""
    return ToxicityModel()


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


def moderate(comments, classifier=None, thresholds=None, labels=HARM_LABELS) -> 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, and `labels`
    has to be the label set of whichever model that is.

    The whole list goes to the classifier in one call. Whether the model then
    reads the comments together is the library's business: transformers
    batches only when given a `batch_size`, and leaves it off by default.
    """
    comments = list(comments)
    if not comments:
        return []
    classifier = classifier or default_model()
    thresholds = thresholds or DEFAULT_THRESHOLDS
    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, labels) for row in scored]


def _decide(scores, thresholds: dict[str, float], labels) -> dict:
    """
    Keep the strongest harm 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.
    """
    rows = scores.items() if isinstance(scores, dict) else ()
    usable = {label: v for label, v in rows if label in labels and _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.jsTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
/**
 * Orienter les commentaires avec un classifieur de toxicité auto-hébergé.
 *
 * Niveau N2. Un encodeur affiné sur les commentaires étiquetés d'un défi public
 * de détection de toxicité, qui tourne sur votre machine. Il arrive entraîné,
 * là où N1 vous oblige à rassembler un corpus, et il reste dans votre
 * infrastructure, ce qui compte quand le texte qu'on enverrait ailleurs est
 * l'injure qu'un de vos utilisateurs vient de recevoir.
 *
 * Le modèle et les étiquettes qui ont le droit de décider vont ensemble, et
 * c'est la langue de vos commentaires qui choisit le couple. Celui d'anglais
 * note sept nuisances distinctes, ce qu'on montre à un relecteur ; le
 * multilingue, dont la fiche liste le français parmi quatorze langues, n'en
 * note qu'une. Brancher le modèle anglais sur un forum français rend des
 * nombres, pas des erreurs : c'est l'échec que ce niveau vous tendra le plus
 * volontiers.
 *
 * Ce qui vous appartient à ce niveau n'est pas le modèle, c'est tout ce qui
 * l'entoure : le regroupement en lots, les seuils, les étiquettes qui ont le
 * droit de décider, et la réponse à « que fait le code quand le modèle ne dit
 * rien d'exploitable ». Le modèle lui-même est
 * une boîte noire avec une liste d'étiquettes figée, et la dernière fonction
 * ci-dessous est l'endroit où cela devient votre problème.
 */

// English only: its corpus is Civil Comments. Seven labels name a harm; the
// model also scores which identities a comment mentions (male, muslim,
// black…), and those say who is mentioned, not what is done to them, so they
// are left out and never decide anything here. The ONNX export of the weights
// Python loads, with its files at the root of the repository.
export const ENGLISH = ['protectai/unbiased-toxic-roberta-onnx', [
  'toxicity', 'severe_toxicity', 'obscene', 'identity_attack', 'insult', 'threat', 'sexual_explicit',
], ''];

// Fourteen languages, French among them, and one label for all of them: its
// card lists the languages and publishes a validation score per language. What
// you lose is the breakdown — "insult" or "threat" is what a moderator reads,
// "toxic 0.87" is what is left. Its licence is not the other one's either:
// read both before you ship. The ONNX export of the weights Python loads.
export const MULTILINGUAL = [
  'onnx-community/distilbert-multilingual-toxicity-classifier-ONNX', ['toxic'], 'onnx',
];

// The examples on this page are English, so this file is set to the English
// pair. A French forum swaps the line, and loses the breakdown with it.
export const [MODEL_NAME, HARM_LABELS, SUBFOLDER] = ENGLISH;

// Two thresholds, not one: between "obviously fine" and "obviously not" there
// is a band that belongs to a human. These two numbers are examples, not
// defaults to leave alone: calibrate them on a sample of your own comments that
// somebody has read by hand.
export const DEFAULT_THRESHOLDS = { block: 0.9, review: 0.6 };

export class ModerationUnavailable extends Error {}

/** The real model. A large download: load it once, with `defaultModel`. */
export class ToxicityModel {
  static async load(name = MODEL_NAME, subfolder = SUBFOLDER) {
    const { pipeline } = await import('@huggingface/transformers');
    // Full precision, as in Python; the pipeline truncates to the length the
    // model reads, so whatever sits past it in a very long comment is not scored.
    return new ToxicityModel(await pipeline('text-classification', name, { subfolder, dtype: 'fp32' }));
  }

  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])));
  }
}

let loading;

/** The real model, loaded on first use and kept for the process. */
export function defaultModel() {
  loading ??= ToxicityModel.load().catch((error) => {
    loading = undefined; // a failed load is not kept: the next call retries
    throw error;
  });
  return loading;
}

/**
 * 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, and `labels` has
 * to be the label set of whichever model that is.
 *
 * The whole list goes to the classifier in one call, and Transformers.js runs
 * it through the model in one pass, padded to the longest comment.
 */
export async function moderate(comments, classifier, thresholds = DEFAULT_THRESHOLDS, labels = HARM_LABELS) {
  const batch = [...comments];
  if (batch.length === 0) return [];
  const model = classifier ?? (await defaultModel());
  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, labels));
}

/**
 * Keep the strongest harm 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, labels) {
  const usable = Object.entries(scores ?? {}).filter(
    ([label, v]) => labels.includes(label) && 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 };
}

Essayer

Ce code ne peut pas tourner dans un navigateur. Les sorties ci-dessous sont calculées à la construction du site, en exécutant le vrai code.

Les notes du modèle sont simulées par le double local qui sert aux tests. Ce qui est calculé ici, c’est ce que le code en fait : la décision qu’il en tire, les seuils qu’il applique, et ce qu’il fait quand la réponse est inutilisable.

  1. Une insulte adressée à quelqu’un

    Donné

    dégage de ce forum espèce de blorptard

    Ce qu’il renvoie

    Bloqué, jamais publié

    Note la plus forte : « toxicity » à 0,96.

    Seuils de cette exécution : blocage à 0,90, relecture à 0,60. Un appel pour le lot.

  2. Un commentaire de lecteur ordinaire

    Donné

    le schéma est bien plus clair que le texte, merci

    Ce qu’il renvoie

    Publié

    Note la plus forte : « toxicity » à 0,02.

    Seuils de cette exécution : blocage à 0,90, relecture à 0,60. Un appel pour le lot.

  3. Un avis cinglant, mais pas une insulte

    Donné

    franchement, c’est une idée spectaculairement mauvaise

    Ce qu’il renvoie

    Mis de côté pour un humain

    Note la plus forte : « toxicity » à 0,71.

    Seuils de cette exécution : blocage à 0,90, relecture à 0,60. Un appel pour le lot.

  4. Le même avis, seuils resserrés

    Donné

    le même commentaire, avec un blocage abaissé à 0,70

    Ce qu’il renvoie

    Bloqué, jamais publié

    Note la plus forte : « toxicity » à 0,71.

    Seuils de cette exécution : blocage à 0,70, relecture à 0,30. Un appel pour le lot.

  5. Le modèle ne renvoie rien d’utilisable

    Donné

    la même insulte, mais le modèle répond un tableau de notes vide

    Ce qu’il renvoie

    Mis de côté pour un humain

    Le modèle n’a renvoyé aucune note utilisable. Le code ne publie pas pour autant : il appelle un humain.

    Seuils de cette exécution : blocage à 0,90, relecture à 0,60. Un appel pour le lot.

  6. Une adresse personnelle livrée en passant

    Donné

    au fait, il habite au coin de la rue des Lilas, allez donc lui dire bonjour

    Ce qu’il renvoie

    Publié

    Note la plus forte : « toxicity » à 0,08.

    Seuils de cette exécution : blocage à 0,90, relecture à 0,60. Un appel pour le lot.

    Là, ça casseCe commentaire désigne le domicile de quelqu’un et invite à s’y rendre. Aucune des sept nuisances que note le modèle ne nomme ce préjudice. Les notes basses de ce cas sont simulées, et le code publie ce qui est noté bas : rien n’est faux dans le code, le préjudice n’a pas d’étiquette. Vous héritez de la taxonomie de quelqu’un d’autre, et l’élargir demande un corpus étiqueté à vous.Voir le niveau au-dessus

Ce que ça implique

  • Vos données restent chez vous
  • Résultat toujours identique
  • Se mesure sur un échantillon, pas cas par cas
  • Dépend d’une bibliothèque
  • Empreinte modérée

Ce que cela ajoute à votre périmètre réglementaire

  • Traitement sur votre infrastructure de contenus écrits par vos utilisateurs
  • Décision de modération prise sans intervention humaine dès qu’un commentaire franchit le seuil de blocage
  • La taxonomie du modèle est celle de son auteur : ce qu’elle ne nomme pas ne vous dispense pas d’avoir à le traiter
  • Usages prévus par l’auteur du modèle : la recherche, un réentraînement sur des données construites avec soin, et l’aide aux modérateurs pour repérer plus vite les contenus nuisibles

Point de rupture

Vous héritez de la taxonomie, de la langue et des biais de quelqu’un d’autre. Le modèle de cet extrait est entraîné sur des commentaires anglais — sa fiche dit `language: en` et son corpus est Civil Comments —, et branché sur un forum français il rend des notes, pas des erreurs ; le couple multilingue que l’extrait publie à côté couvre le français, mais avec une seule étiquette, « toxic », si bien que le relecteur reçoit un nombre et plus une raison. Le modèle note sept nuisances — toxicité, toxicité sévère, obscénité, attaque identitaire, insulte, menace, contenu sexuel explicite — et aucune ne nomme la divulgation d’un domicile : « he lives at the corner of rue des Lilas by the way, go and say hello » n’a pas d’étiquette où tomber. Et la fiche du modèle prévient qu’un commentaire contenant des mots d’insulte sera probablement noté toxique, quels que soient le ton et l’intention : le signalement d’une insulte n’y échappe pas. Et le commentaire n’est noté que jusqu’à la longueur que le modèle lit : une insulte à la fin d’un long message passe. Le test ne vérifie que la plomberie, sur des notes simulées.

Quand passer au niveau suivant

Le préjudice qui vous occupe n’a d’étiquette dans aucun modèle disponible, et vous n’avez pas de corpus étiqueté pour la lui apprendre.

Les quatre options

  1. N0Liste de termes après normalisation, avec fenêtre de contexteGratuit · Instantané
  2. N1Classifieur linéaire sur n-grammes de caractèresnégligeable · Instantané
  3. RecommandéN2Classifieur de toxicité auto-hébergé (anglais, ou multilingue à une étiquette), avec bande de relecture humainePeu coûteux · Rapide
  4. N3Notation du commentaire par un modèle généraliste, pour la catégorie qui n’a d’étiquette nulle partélevé · Une seconde

Les autres options

N0Liste de termes après normalisation, avec fenêtre de contexteGratuit · Instantané

Liste de termes après normalisation, avec fenêtre de contexte

Règle et algorithme classiqueN0GratuitInstantané

Python

snippets/moderate-user-comments/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Signaler un commentaire contre une liste de termes, après normalisation, en
rendant le contexte.

Niveau N0. Déterministe, aucune dépendance, et vérifiable : chaque décision
se ramène à une entrée d'une liste que vous tenez.

Deux choses le rendent utilisable, et pas seulement simple.

D'abord, la normalisation. La casse, les accents et les formes de
compatibilité (lettres pleine largeur, ligatures, exposants) sont des
graphies du même mot : ils sont réduits avant la comparaison. Les sosies ne
le sont pas : un 0 reste un 0, et c'est pourquoi bl0rptard passe. La
décomposition vient avant le repli de casse, et de nouveau après : sinon une
capitale de compatibilité passe sans être repliée. Et seuls les accents
latins sont retirés : une voyelle du devanagari est une marque, elle aussi,
et la retirer changerait le mot.

Ensuite, la fenêtre de contexte. Une liste de termes ne peut rien décider
seule, donc la fonction renvoie les mots autour de chaque trouvaille. Un
humain lit la fenêtre et tranche. Un outil de modération qui renvoie un
simple booléen cache la seule pièce dont son relecteur a besoin.
"""

import unicodedata

# Latin combining accents. Only those are dropped when folding: a Devanagari
# vowel sign is a mark too, and dropping it would turn one word into another.
LATIN_MARKS = ("\u0300", "\u036f")


def normalise(text: str) -> str:
    """Fold compatibility forms, then case, then Latin accents."""
    # Decomposed before folding, and again after: 𝐁𝐋𝐎𝐑𝐏𝐓𝐀𝐑𝐃 has to become
    # BLORPTARD before the case fold can see it, and folding can itself produce
    # a compatibility form.
    decomposed = unicodedata.normalize("NFKD", text)
    folded = unicodedata.normalize("NFKD", decomposed.casefold())
    return "".join(c for c in folded if not LATIN_MARKS[0] <= c <= LATIN_MARKS[1])


def _words(text: str) -> list[str]:
    """
    Letters, digits, and the marks that spell them; anything else separates.

    A regular expression does this in JavaScript, which has a class for marks;
    Python's does not, and its word class leaves marks out, so a Devanagari
    vowel sign would cut a word in two. Composed first, so an accent typed as a
    separate mark stays in its word.
    """
    words, current = [], []
    for char in unicodedata.normalize("NFC", text):
        if char.isalnum() or unicodedata.category(char).startswith("M"):
            current.append(char)
        elif current:
            words.append("".join(current))
            current = []
    if current:
        words.append("".join(current))
    return words


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

    A term of several words matches those words in a row, whatever punctuation
    separates them in the comment.

    `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 = {tuple(normalise(w) for w in _words(t)) for t in terms}
    longest = max(map(len, listed), default=0)
    words = _words(text)
    folded = [normalise(w) for w in words]
    matches = []
    for position in range(len(words)):
        for size in range(1, min(longest, len(words) - position) + 1):
            if tuple(folded[position:position + size]) in listed:
                matches.append({
                    "term": " ".join(folded[position:position + size]),
                    "position": position,
                    "context": " ".join(words[max(0, position - window):position + size + window]),
                })
    return {"flagged": bool(matches), "matches": matches}

JavaScript

snippets/moderate-user-comments/n0.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Signaler un commentaire contre une liste de termes, après normalisation, en
 * rendant le contexte.
 *
 * Niveau N0. Déterministe, aucune dépendance, et vérifiable : chaque décision
 * se ramène à une entrée d'une liste que vous tenez.
 *
 * Deux choses le rendent utilisable, et pas seulement simple.
 *
 * D'abord, la normalisation. La casse, les accents et les formes de
 * compatibilité (lettres pleine largeur, ligatures, exposants) sont des
 * graphies du même mot : ils sont réduits avant la comparaison. Les sosies ne
 * le sont pas : un 0 reste un 0, et c'est pourquoi bl0rptard passe. La
 * décomposition vient avant le repli de casse, et de nouveau après : sinon une
 * capitale de compatibilité passe sans être repliée. Et seuls les accents
 * latins sont retirés : une voyelle du devanagari est une marque, elle aussi,
 * et la retirer changerait le mot.
 *
 * Ensuite, la fenêtre de contexte. Une liste de termes ne peut rien décider
 * seule, donc la fonction renvoie les mots autour de chaque trouvaille. Un
 * humain lit la fenêtre et tranche. Un outil de modération qui renvoie un
 * simple booléen cache la seule pièce dont son relecteur a besoin.
 */

// Letters, digits, and the marks that spell them; anything else separates. A
// Devanagari vowel sign is a mark, and leaving it out would cut a word in two.
const TOKEN = /[\p{L}\p{N}\p{M}]+/gu;

/** Fold compatibility forms, then case, then Latin accents. */
export function normalise(text) {
  // Decomposed before folding, and again after: 𝐁𝐋𝐎𝐑𝐏𝐓𝐀𝐑𝐃 has to become
  // BLORPTARD before the case fold can see it. JavaScript has no casefold, so
  // `toLowerCase` does the work and the two letters a Latin term list meets,
  // ß and the final ς, are done by hand; there are two hundred and fifty-two
  // others, in Cherokee, Greek and Cyrillic, that this line does not cover.
  return text
    .normalize('NFKD')
    .toLowerCase()
    .normalize('NFKD')
    .replaceAll('ß', 'ss')
    .replaceAll('ς', 'σ')
    .replace(/[\u0300-\u036f]/gu, '');
}

// Composed first, so an accent typed as a separate mark stays in its word.
const words = (text) => text.normalize('NFC').match(TOKEN) ?? [];

/**
 * Return every listed term found in `text`, with the words around it.
 *
 * A term of several words matches those words in a row, whatever punctuation
 * separates them in the comment.
 *
 * `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((t) => words(t).map(normalise).join(' ')));
  const longest = [...terms].reduce((most, t) => Math.max(most, words(t).length), 0);
  const found = words(text);
  const folded = found.map(normalise);
  const matches = [];
  for (let position = 0; position < found.length; position += 1) {
    for (let size = 1; size <= Math.min(longest, found.length - position); size += 1) {
      const term = folded.slice(position, position + size).join(' ');
      if (!listed.has(term)) continue;
      matches.push({
        term,
        position,
        context: found.slice(Math.max(0, position - window), position + size + window).join(' '),
      });
    }
  }
  return { flagged: matches.length > 0, matches };
}

Ce que ça implique

  • Vos données ne sortent pas
  • Résultat toujours identique
  • Se teste unitairement, cas par cas
  • Aucun fournisseur dans la boucle
  • Empreinte négligeable

Ce que cela ajoute à votre périmètre réglementaire

  • Aucun périmètre spécifique ajouté : le commentaire ne quitte pas votre infrastructure, et l’extrait signale sans trancher

Point de rupture

Deux moitiés, toutes deux dans le test. Une graphie absente de la liste passe intacte : « bl0rptard », « blorp-tard », les lettres espacées une à une. Et la liste voit le mot, jamais son emploi : le message « he called me a blorptard, please remove his comment » est signalé exactement comme l’insulte qu’il rapporte : même drapeau, même terme, et seule la fenêtre de contexte, qu’un humain doit lire, les distingue.

Quand passer au niveau suivant

Vos modérateurs passent leur journée à classer sans suite des signalements qui n’en sont pas, ou vous ajoutez une graphie de plus à la liste chaque semaine.

N1Classifieur linéaire sur n-grammes de caractèresnégligeable · Instantané

Classifieur linéaire sur n-grammes de caractères

Modèle classique légerN1négligeableInstantané

Python

snippets/moderate-user-comments/n1.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Noter un commentaire avec un classifieur linéaire entraîné sur un corpus
étiqueté.

Niveau N1. La liste de termes de N0 reconnaît des graphies. Celui-ci
reconnaît des formes : des n-grammes de caractères, si bien que
« bl0rptard » et « blorptardd » partagent l'essentiel de leurs traits avec
la forme montrée au modèle, et qu'une variante que personne n'a ajoutée à
une liste obtient quand même une note.

Le modèle entier est un vecteur de poids sur des n-grammes de caractères.
Il est assez petit pour vivre à côté du code, il s'entraîne le temps que
vous lisiez cette docstring, et chaque poids peut être imprimé à côté du
n-gramme auquel il appartient. Quelqu'un demandera pourquoi son commentaire
a été masqué.
"""

import unicodedata

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


def fold(comment: str) -> str:
    """NFKC then lowercase, so full-width letters and ligatures read as the plain ones."""
    return unicodedata.normalize("NFKC", comment).lower()


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), preprocessor=fold),
        # `C` above one because a couple of dozen short examples under the
        # default regulariser leave every score sitting near a half, which
        # makes the threshold in `is_abusive` meaningless.
        LogisticRegression(class_weight="balanced", C=10.0, 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. A comment
    with no n-gram to read scores 0: the intercept alone is not evidence.
    """
    if model[0].transform([comment]).nnz == 0:
        return 0.0
    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.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Noter un commentaire avec un classifieur linéaire entraîné sur un corpus
 * étiqueté.
 *
 * Niveau N1. La liste de termes de N0 reconnaît des graphies. Celui-ci
 * reconnaît des formes : des n-grammes de caractères, si bien que
 * « bl0rptard » et « blorptardd » partagent l'essentiel de leurs traits avec
 * la forme montrée au modèle, et qu'une variante que personne n'a ajoutée à
 * une liste obtient quand même une note.
 *
 * Écrit à la main plutôt que tiré d'une bibliothèque : hacher les n-grammes
 * de caractères, entraîner une régression logistique et noter tiennent en
 * quatre fonctions courtes. Le modèle est un vecteur de poids, assez petit
 * pour vivre à côté du code et entraîné le temps que vous lisiez ceci. Chaque
 * poids peut être imprimé, mais avec le hachage un poids vaut pour tous les
 * n-grammes qui tombent dans sa case : pour en discuter un, listez d'abord ces
 * n-grammes. Quelqu'un demandera pourquoi son commentaire a été masqué.
 */

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

/** Character n-grams of the folded comment, hashed into a fixed vector. */
function features(text) {
  // NFKC first, so full-width letters and ligatures read as the plain ones.
  const padded = ` ${text.normalize('NFKC').toLowerCase()} `;
  const vector = new Float64Array(BUCKETS);
  for (let n = 3; n <= 5; n += 1) {
    for (let i = 0; i + n <= padded.length; i += 1) {
      // An n-gram of nothing but blanks is not evidence about a comment: a
      // form submitted empty would otherwise be scored like any other text.
      // Python never builds one, its analyser padding each word rather than
      // the whole comment.
      if (padded.slice(i, i + n).trim() === '') continue;
      let h = 2166136261;
      // Math.imul keeps the multiplication exact on 32 bits; a plain `*` goes
      // past 2^53, loses the low bits, and piles n-grams into fewer buckets.
      for (const c of padded.slice(i, i + n)) h = Math.imul(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 counts = [labels.filter((l) => l === 0).length, labels.filter((l) => l === 1).length];
  // Both classes, one label per comment: otherwise the weights learn nothing
  // and every comment gets the same verdict.
  if (!counts[0] || !counts[1] || counts[0] + counts[1] !== comments.length) {
    throw new RangeError('train needs labels 0 and 1, one per comment');
  }
  const rows = comments.map(features);
  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. A comment
 * with no n-gram to read scores 0: the bias alone is not evidence.
 */
export function score(model, comment) {
  const vector = features(comment);
  return vector.some((v) => v !== 0) ? predict(vector, model.weights, model.bias) : 0;
}

/**
 * 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;
}

Ce que ça implique

  • Vos données restent chez vous
  • Résultat toujours identique
  • Se mesure sur un échantillon, pas cas par cas
  • Dépend d’une bibliothèque
  • Empreinte faible

Ce que cela ajoute à votre périmètre réglementaire

  • Traitement sur votre infrastructure de contenus écrits par vos utilisateurs
  • Le corpus d’entraînement conserve des commentaires réels, et les décisions d’étiquetage qui les accompagnent

Point de rupture

Le modèle apprend des fragments de texte, pas une intention. Le test le montre des deux côtés : « people like you should not be allowed to have an account here », qui n’emploie aucune des insultes du corpus, reste sous le seuil, tandis que le signalement « he called me a blorptard, please remove his comment » passe au-dessus. Un corpus plus fourni change les poids, pas ce que le modèle lit : des n-grammes de caractères.

Quand passer au niveau suivant

L’hostilité qui vous échappe est écrite en langue ordinaire, sans un seul terme que vous puissiez lister ou étiqueter.

N3Notation du commentaire par un modèle généraliste, pour la catégorie qui n’a d’étiquette nulle partélevé · Une seconde

Notation du commentaire par un modèle généraliste, pour la catégorie qui n’a d’étiquette nulle part

API de LLM généralisteN3élevéUne seconde

Python

snippets/moderate-user-comments/n3.pyTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
"""
Noter un commentaire en interrogeant un modèle généraliste.

Niveau N3. Le niveau qui abandonne le plus : le sens de chaque catégorie,
l'étalonnage, le droit de contester, et le texte des commentaires de vos
utilisateurs, qui sort de chez vous à chaque appel.

Avant d'écrire ceci, regardez si votre fournisseur publie un point d'accès de
modération. Celui pris en exemple ici en publie un, sans compteur, qui note
treize catégories figées. Si les nuisances qui vous occupent sont dans cette
liste, ce fichier est la façon chère de les obtenir. Ce qu'il achète, et la
seule chose qu'il achète, c'est une catégorie qui est la vôtre : la dernière
ci-dessous nomme la divulgation du domicile de quelqu'un, qu'aucune taxonomie
figée de cette page ne porte — ni les treize du fournisseur, ni les
étiquettes du modèle de N2.

Vous nommez les catégories ; le modèle décide de ce qu'elles
veulent dire. Tout le reste ici — plafonner l'entrée, réessayer, refuser
d'agir sur une réponse qui n'a pas la forme demandée — est une plomberie
qui vous appartient, et c'est là que vivent les bogues de ce niveau. C'est
aussi tout ce que vos tests peuvent atteindre, parce que le jugement
lui-même n'est pas testable.
"""

from __future__ import annotations

import json

# The provider named here is an example, not a recommendation: the reasoning
# holds for any general-purpose model API, and the client is swappable. Pass
# any object with a `complete(prompt=..., temperature=...)` method.
MODEL = "gpt-4.1-mini"  # an example id: check the parameters your model accepts

# The first four are on the provider's own moderation endpoint too. The last
# one is not, and it is the reason this file exists.
CATEGORIES = ("harassment", "hate", "violence", "self_harm", "personal_information")

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"
    "personal_information means giving out where somebody lives, works, or how\n"
    "to reach them, without their consent.\n\nComment:\n{comment}"
)

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


class ProviderClient:
    """The one call this snippet makes, on top of the provider's SDK."""

    def __init__(self, sdk=None, model: str = MODEL):
        if sdk is None:  # pragma: no cover - needs a key and a network
            from openai import OpenAI

            sdk = OpenAI()
        self.sdk, self.model = sdk, model

    def complete(self, *, prompt: str, temperature: float) -> str:
        response = self.sdk.chat.completions.create(
            model=self.model,
            messages=[{"role": "user", "content": prompt}],
            temperature=temperature,
        )
        return response.choices[0].message.content


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.
    """
    # The provider bills every token of the prompt. The cap counts characters,
    # not tokens, and is checked before any call: the caller decides where a
    # longer comment goes instead. On a forum that is the review queue, not the
    # bin — a comment of four thousand and one characters is not an anomaly.
    if len(comment) > MAX_CHARACTERS:
        raise ValueError(f"comment longer than {MAX_CHARACTERS} characters")

    client = client or ProviderClient()
    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:
            # Temperature zero, because a moderation decision that changes between
            # two identical calls cannot be explained to the person it hit.
            answer = client.complete(prompt=PROMPT.format(comment=comment), temperature=0)
            # No content (a refusal) and anything but a JSON object are unusable.
            parsed = json.loads(_unfenced(answer)) if isinstance(answer, str) else None
            if not isinstance(parsed, dict):
                last_error = ValueError("the answer is not a JSON object")
                continue
            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 _unfenced(answer: str) -> str:
    """
    A JSON answer wrapped whole in one code fence is read; nothing else is.

    Prose around it, a second block, or a fence never closed is a failed
    answer, and it is asked for again rather than salvaged.
    """
    text = answer.strip()
    if text.startswith("```") and text.endswith("```") and text.count("```") == 2:
        return text[3:-3].removeprefix("json")
    return text


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.jsTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
/**
 * Noter un commentaire en interrogeant un modèle généraliste.
 *
 * Niveau N3. Le niveau qui abandonne le plus : le sens de chaque catégorie,
 * l'étalonnage, le droit de contester, et le texte des commentaires de vos
 * utilisateurs, qui sort de chez vous à chaque appel.
 *
 * Avant d'écrire ceci, regardez si votre fournisseur publie un point d'accès de
 * modération. Celui pris en exemple ici en publie un, sans compteur, qui note
 * treize catégories figées. Si les nuisances qui vous occupent sont dans cette
 * liste, ce fichier est la façon chère de les obtenir. Ce qu'il achète, et la
 * seule chose qu'il achète, c'est une catégorie qui est la vôtre : la dernière
 * ci-dessous nomme la divulgation du domicile de quelqu'un, qu'aucune taxonomie
 * figée de cette page ne porte — ni les treize du fournisseur, ni les
 * étiquettes du modèle de N2.
 *
 * Vous nommez les catégories ; le modèle décide de ce qu'elles
 * veulent dire. Tout le reste ici — plafonner l'entrée, réessayer, refuser
 * d'agir sur une réponse qui n'a pas la forme demandée — est une plomberie
 * qui vous appartient, et c'est là que vivent les bogues de ce niveau. C'est
 * aussi tout ce que vos tests peuvent atteindre, parce que le jugement
 * lui-même n'est pas testable.
 */

// The provider named here is an example, not a recommendation: the reasoning
// holds for any general-purpose model API, and the client is swappable. Pass
// any object with a `complete({ prompt, temperature })` method.
export const MODEL = 'gpt-4.1-mini'; // an example id: check the parameters your model accepts

export async function providerClient(sdk, model = MODEL) {
  if (!sdk) {
    const { OpenAI } = await import('openai');
    sdk = new OpenAI();
  }
  return {
    async complete({ prompt, temperature }) {
      const response = await sdk.chat.completions.create({
        model,
        messages: [{ role: 'user', content: prompt }],
        temperature,
      });
      return response.choices[0].message.content;
    },
  };
}

// The first four are on the provider's own moderation endpoint too. The last
// one is not, and it is the reason this file exists.
export const CATEGORIES = ['harassment', 'hate', 'violence', 'self_harm', 'personal_information'];

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(', ')}`,
  'personal_information means giving out where somebody lives, works, or how',
  'to reach them, without their consent.',
  '',
  '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 } = {}) {
  // The provider bills every token of the prompt. The cap counts characters
  // (code points, as Python does), not tokens, and is checked before any call:
  // the caller decides where a longer comment goes instead. On a forum that is
  // the review queue, not the bin — a comment of four thousand and one
  // characters is not an anomaly.
  if ([...comment].length > MAX_CHARACTERS) {
    throw new RangeError(`comment longer than ${MAX_CHARACTERS} characters`);
  }

  client ??= await providerClient();
  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,
      });
      // No content (a refusal) and anything but a JSON object are unusable.
      const parsed = typeof answer === 'string' ? JSON.parse(unfenced(answer)) : null;
      if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
        lastError = new Error('the answer is not a JSON object');
        continue;
      }
      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. */
/**
 * A JSON answer wrapped whole in one code fence is read; nothing else is.
 *
 * Prose around it, a second block, or a fence never closed is a failed answer,
 * and it is asked for again rather than salvaged.
 */
function unfenced(answer) {
  const text = answer.trim();
  const fences = text.match(/```/g)?.length ?? 0;
  if (text.startsWith('```') && text.endsWith('```') && fences === 2) {
    const inner = text.slice(3, -3);
    return inner.startsWith('json') ? inner.slice(4) : inner;
  }
  return text;
}

function isScore(value) {
  return typeof value === 'number' && value >= 0 && value <= 1;
}

Ce que ça implique

  • Vos données partent chez un tiers
  • Résultat variable d’un appel à l’autre
  • Difficile à tester
  • Dépend d’un fournisseur externe
  • Empreinte élevée

Ce que cela ajoute à votre périmètre réglementaire

  • Transfert à un sous-traitant du texte de vos utilisateurs, y compris celui des personnes qui signalent l’abus qu’elles viennent de subir
  • Localisation du traitement à vérifier auprès du fournisseur
  • Ne vous dispense pas d’être en mesure de motiver une décision que le fournisseur a prise à votre place

Point de rupture

Vous payez au jeton ce que le point d’accès de modération du même fournisseur rend sans compteur. Ce qui le justifie tient en une ligne de l’extrait : la cinquième catégorie, la divulgation d’un domicile, que ni les treize catégories de ce point d’accès ni les étiquettes du modèle de N2 ne portent. Pour les quatre autres, ce niveau est la façon chère d’obtenir ce qui est gratuit. Le score, lui, est l’avis du fournisseur, et le code n’a rien pour le confronter. Si « the diagram is much clearer than the text » revient noté en harcèlement au-delà du seuil de blocage — le test simule cette réponse —, la fonction bloque, correctement selon sa propre logique. Aucun trait à inspecter, aucun poids à imprimer, et rien à répondre à l’auteur du commentaire hormis ce nombre, que le fournisseur pris en exemple ne garantit pas identique d’un appel à l’autre, même à graine fixée.

Quand passer au niveau suivant

Il n’y a pas de niveau au-dessus.

Pour aller plus loin

Source des chiffres

Métadonnées