Masquer les coordonnées dans un message

Empêcher qu’un numéro de téléphone, une adresse électronique ou un IBAN soit visible dans un message envoyé. Ni un nom ni une adresse postale : ce sont aussi des données personnelles, et aucun niveau de cette fiche ne les touche.

Pas besoin d’IA

GratuitInstantanéVos données ne sortent pas

Le verdict

N0 : le besoin est déterministe, le coût marginal est nul, rien ne sort de votre infrastructure, et le résultat se teste unitairement, ce qui compte quand un faux négatif publie le numéro de quelqu’un. N1 ne remplace pas N0, il s’y ajoute : il ne masque pas, il signale un message entier à bloquer ou à envoyer en modération. Ajoutez-le le jour où vous constatez des contournements délibérés, pas avant : vous prendriez un jeu d’entraînement à maintenir, et vos utilisateurs verraient leur message refusé au lieu d’être publié amputé d’un numéro. Quant à N3, il envoie au fournisseur le message entier pour en retirer des données personnelles : c’est la première raison de ne pas le prendre.

Normalisation puis expressions régulières

Pas besoin d’IA

Règle et algorithme classiqueN0GratuitInstantané

Python

snippets/mask-personal-data-in-chat/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Masquer les coordonnées dans un message : normalisation, puis expressions
régulières.

Niveau N0. Déterministe, bibliothèque standard seule. Les coordonnées, et
elles seules : un numéro de téléphone, une adresse électronique, un IBAN. Un
nom, une adresse postale ou une date de naissance sont aussi des données
personnelles, et rien ici n'y touche.

Deux choses font que ça marche.

D'abord, les motifs lisent une copie normalisée du message, où chaque
caractère est écrit sous sa forme ordinaire : le repli de compatibilité
ramène les chiffres pleine largeur à des chiffres ordinaires, et les espaces
de la typographie française, les caractères sans chasse et le trait d'union
conditionnel deviennent une espace simple. Les étiquettes sont ensuite posées
dans le message tel qu'il a été écrit : un message sans rien à masquer
ressort inchangé, à la composition de ses accents près (NFC), qui ne change
rien à l'écran.

Ensuite, les motifs de téléphone tolèrent les séparateurs que les gens tapent
entre les chiffres — espace, point, tiret ou barre oblique, un ou deux — et un
IBAN n'est masqué que si sa clé de contrôle tombe juste.
"""

import re
import unicodedata

# Characters that can sit inside a number without looking like a separator:
# the spaces of French typography, the soft hyphen, the zero-width space and
# joiners, the word joiner and the byte order mark.
INVISIBLE_OR_SPACE = re.compile("[\u00a0\u00ad\u2007\u2009\u200a\u200b\u200c\u200d\u202f\u2060\ufeff]")

# The lookbehind starts a match only at the beginning of a word: without it, a
# long run of letters with no @ takes quadratic time.
EMAIL = re.compile(r"(?<![\w.+-])[\w.+-]+@[\w-]+(?:\.[\w-]+)+")

# French numbers: 0X XX XX XX XX, +33 X XX XX XX XX, or +33 (0)X XX XX XX XX.
# Between digits: a space, a dot, a dash or a slash, one or two of them, or nothing.
SEP = r"[ ./-]{0,2}"
PHONE = re.compile(rf"(?<![\d+])(?:\+{SEP}33{SEP}(?:\(0\){SEP})?|0)[1-9](?:{SEP}\d){{8}}(?!\d)", re.ASCII)

# Any other country: a code introduced by + or 00, then eight to fifteen digits
# in all. Fifteen is the maximum length of a number in recommendation ITU-T
# E.164, so a longer run of digits is something else and stays untouched.
# Without this pattern a Belgian, Swiss or British number goes through
# unmasked, which on a marketplace is not an evasion, it is a user.
INTERNATIONAL = re.compile(rf"(?<![\d+])(?:\+|00){SEP}\d(?:{SEP}\d){{7,14}}(?!\d)", re.ASCII)

# IBAN: two letters, two check digits, then the account number in groups of
# four, spaced or not. Upper case only, the way ISO 13616 prints one: read in
# either case, any sentence whose words happened to fall in fours and whose
# digits happened to pass the check would be masked — "le 10 mars 2023 pour"
# does. The check digits decide the rest, not the pattern.
IBAN = re.compile(r"(?<![A-Z0-9])[A-Z]{2} ?\d{2}(?: ?[A-Z0-9]{4}){2,7}(?: ?[A-Z0-9]{1,3})?(?![A-Z0-9])", re.ASCII)

# Order matters: an email may contain digits that would otherwise be read as
# the start of a phone number, and the French pattern is tried before the
# international one so that "+33 (0)6 …" is read as one number.
PATTERNS = ((EMAIL, "[email]"), (IBAN, "[iban]"), (PHONE, "[phone]"), (INTERNATIONAL, "[phone]"))


def normalise(text: str) -> str:
    """Write every character in its plain form: a digit as a digit, a space as a space."""
    return "".join(_plain(char) for char in text)


def _plain(char: str) -> str:
    return " " if INVISIBLE_OR_SPACE.match(char) else unicodedata.normalize("NFKC", char)


def is_valid_iban(candidate: str) -> bool:
    """ISO 13616: the first four characters go to the end, letters count 10 to 35, modulo 97 is 1."""
    compact = candidate.replace(" ", "").upper()
    return len(compact) >= 15 and int("".join(str(int(c, 36)) for c in compact[4:] + compact[:4])) % 97 == 1


def iban_prefix(candidate: str) -> str:
    """The longest valid IBAN at the start of `candidate`, cut at a space: the pattern may swallow the next word."""
    cuts = [len(candidate)] + [space.start() for space in re.finditer(" ", candidate)][::-1]
    return next((candidate[:cut] for cut in cuts if is_valid_iban(candidate[:cut])), "")


def mask(text: str) -> str:
    """
    Replace contact details with a label naming what was removed.

    A label beats a row of asterisks: whoever reads the thread later can see
    that a phone number was removed, not merely that something was.
    """
    text = unicodedata.normalize("NFC", text)
    plain, origin = "", []  # origin[i]: the character of `text` that plain character i comes from
    for index, char in enumerate(text):
        plain += _plain(char)
        origin += [index] * (len(plain) - len(origin))

    covered = [None] * len(text)  # (label, first character) of the match covering each character
    for pattern, label in PATTERNS:
        for match in pattern.finditer(plain):
            found = iban_prefix(match.group()) if label == "[iban]" else match.group()
            if not found:
                continue
            start, end = origin[match.start()], origin[match.start() + len(found) - 1] + 1
            if all(cover is None for cover in covered[start:end]):
                covered[start:end] = [(label, start)] * (end - start)

    # Each character is kept, replaced by its label where a match starts, or dropped.
    return "".join(
        char if cover is None else cover[0] if cover[1] == index else ""
        for index, (char, cover) in enumerate(zip(text, covered))
    )

JavaScript

snippets/mask-personal-data-in-chat/n0.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Masquer les coordonnées dans un message : normalisation, puis expressions
 * régulières.
 *
 * Niveau N0. Déterministe, bibliothèque standard seule. Les coordonnées, et
 * elles seules : un numéro de téléphone, une adresse électronique, un IBAN. Un
 * nom, une adresse postale ou une date de naissance sont aussi des données
 * personnelles, et rien ici n'y touche.
 *
 * Deux choses font que ça marche.
 *
 * D'abord, les motifs lisent une copie normalisée du message, où chaque
 * caractère est écrit sous sa forme ordinaire : le repli de compatibilité
 * ramène les chiffres pleine largeur à des chiffres ordinaires, et les espaces
 * de la typographie française, les caractères sans chasse et le trait d'union
 * conditionnel deviennent une espace simple. Les étiquettes sont ensuite posées
 * dans le message tel qu'il a été écrit : un message sans rien à masquer
 * ressort inchangé, à la composition de ses accents près (NFC), qui ne change
 * rien à l'écran.
 *
 * Ensuite, les motifs de téléphone tolèrent les séparateurs que les gens tapent
 * entre les chiffres — espace, point, tiret ou barre oblique, un ou deux — et un
 * IBAN n'est masqué que si sa clé de contrôle tombe juste.
 */

// Characters that can sit inside a number without looking like a separator:
// the spaces of French typography, the soft hyphen, the zero-width space and
// joiners, the word joiner and the byte order mark.
const INVISIBLE_OR_SPACE = /[\u00a0\u00ad\u2007\u2009\u200a\u200b\u200c\u200d\u202f\u2060\ufeff]/;

// The lookbehind starts a match only at the beginning of a word: without it, a
// long run of letters with no @ takes quadratic time. The `u` flag and \p{…}
// classes let accented letters in, as \w does in Python.
const EMAIL = /(?<![\p{L}\p{N}_.+-])[\p{L}\p{N}_.+-]+@[\p{L}\p{N}_-]+(?:\.[\p{L}\p{N}_-]+)+/gu;

// French numbers: 0X XX XX XX XX, +33 X XX XX XX XX, or +33 (0)X XX XX XX XX.
// Between digits: a space, a dot, a dash or a slash, one or two of them, or nothing.
const SEP = '[ ./-]{0,2}';
const PHONE = new RegExp(`(?<![\\d+])(?:\\+${SEP}33${SEP}(?:\\(0\\)${SEP})?|0)[1-9](?:${SEP}\\d){8}(?!\\d)`, 'g');

// Any other country: a code introduced by + or 00, then eight to fifteen
// digits in all. Fifteen is the maximum length of a number in recommendation
// ITU-T E.164, so a longer run of digits is something else and stays untouched.
// Without this pattern a Belgian, Swiss or British number goes through
// unmasked, which on a marketplace is not an evasion, it is a user.
const INTERNATIONAL = new RegExp(`(?<![\\d+])(?:\\+|00)${SEP}\\d(?:${SEP}\\d){7,14}(?!\\d)`, 'g');

// IBAN: two letters, two check digits, then the account number in groups of
// four, spaced or not. Upper case only, the way ISO 13616 prints one: read in
// either case, any sentence whose words happened to fall in fours and whose
// digits happened to pass the check would be masked — "le 10 mars 2023 pour"
// does. The check digits decide the rest, not the pattern.
const IBAN = /(?<![A-Z0-9])[A-Z]{2} ?\d{2}(?: ?[A-Z0-9]{4}){2,7}(?: ?[A-Z0-9]{1,3})?(?![A-Z0-9])/g;

// Order matters: an email may contain digits that would otherwise be read as
// the start of a phone number, and the French pattern is tried before the
// international one so that "+33 (0)6 …" is read as one number.
const PATTERNS = [[EMAIL, '[email]'], [IBAN, '[iban]'], [PHONE, '[phone]'], [INTERNATIONAL, '[phone]']];

const plain = (char) => (INVISIBLE_OR_SPACE.test(char) ? ' ' : char.normalize('NFKC'));

/** Write every character in its plain form: a digit as a digit, a space as a space. */
export function normalise(text) {
  return [...text].map(plain).join('');
}

/** ISO 13616: the first four characters go to the end, letters count 10 to 35, modulo 97 is 1. */
function isValidIban(candidate) {
  const compact = candidate.replace(/ /g, '').toUpperCase();
  const digits = [...compact.slice(4) + compact.slice(0, 4)].map((c) => parseInt(c, 36)).join('');
  return compact.length >= 15 && BigInt(digits) % 97n === 1n;
}

/** The longest valid IBAN at the start of `candidate`, cut at a space: the pattern may swallow the next word. */
function ibanPrefix(candidate) {
  const cuts = [candidate.length, ...[...candidate.matchAll(/ /g)].map((space) => space.index).reverse()];
  const cut = cuts.find((at) => isValidIban(candidate.slice(0, at)));
  return cut === undefined ? '' : candidate.slice(0, cut);
}

/**
 * Replace contact details with a label naming what was removed.
 *
 * A label beats a row of asterisks: whoever reads the thread later can see
 * that a phone number was removed, not merely that something was.
 */
export function mask(text) {
  const chars = [...text.normalize('NFC')];
  const origin = []; // origin[i]: the character that code unit i of the plain copy comes from
  let copy = '';
  chars.forEach((char, index) => {
    copy += plain(char);
    while (origin.length < copy.length) origin.push(index);
  });

  const covered = new Array(chars.length).fill(null); // [label, first character] of the match covering each character
  for (const [pattern, label] of PATTERNS) {
    for (const match of copy.matchAll(pattern)) {
      const found = label === '[iban]' ? ibanPrefix(match[0]) : match[0];
      if (!found) continue;
      const start = origin[match.index];
      const end = origin[match.index + found.length - 1] + 1;
      if (covered.slice(start, end).every((cover) => cover === null)) covered.fill([label, start], start, end);
    }
  }

  // Each character is kept, replaced by its label where a match starts, or dropped.
  return chars.map((char, i) => (covered[i] === null ? char : covered[i][1] === i ? covered[i][0] : '')).join('');
}

Essayer

interactif

Le code ci-dessus tourne dans votre navigateur. Écrivez dans le champ : le résultat se refait sous vos yeux.

à vous d’écrire

Recalculé à chaque frappe.

Ou partez d’un exemple

La saisie libre et les autres exemples ont besoin de JavaScript. L’exemple affiché ci-dessous, lui, a été calculé à la construction du site.

Ce que le code en fait

2 passages attrapés

Ce que le code a repéré

Bonjour, appelez-moi au 06 12 34 56 78 ou écrivez à marie.durand@exemple.fr

Ce qu’il renvoie

Bonjour, appelez-moi au [phone] ou écrivez à [email]

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 message ne quitte pas votre infrastructure

Point de rupture

L’obfuscation volontaire : « jean [at] example [dot] com », « O6 I2 34 » avec des lettres à la place des chiffres, « zéro six douze ». Rien de tout cela ne ressemble à une coordonnée pour un motif. Dans l’autre sens, une référence de commande à dix chiffres est masquée en « [phone] », et un IBAN de coïncidence — « DE 86 3456 7890 1234 » passe la clé modulo 97 — en « [iban] ».

Quand passer au niveau suivant

Vos utilisateurs contournent activement le filtre, et vous le voyez dans les messages signalés.

Les quatre options

  1. RecommandéN0Normalisation puis expressions régulièresGratuit · Instantané
  2. N1Détection d’un message à coordonnées déguisées, par régression logistique sur la forme des jetonsnégligeable · Instantané
  3. N2Niveau sans objet ici
  4. N3Extraction structurée par appel à un modèle généralisteélevé · Une seconde

Les autres options

N1Détection d’un message à coordonnées déguisées, par régression logistique sur la forme des jetonsnégligeable · Instantané

Détection d’un message à coordonnées déguisées, par régression logistique sur la forme des jetons

Modèle classique légerN1négligeableInstantané

Python

snippets/mask-personal-data-in-chat/n1.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Rattraper les coordonnées obfusquées avec un classifieur léger.

Niveau N1. Les expressions régulières du niveau N0 voient une façon d'écrire
un numéro. Celui-ci en voit la forme : une suite de jetons surtout faits de
chiffres, plutôt courts, et posés à côté de mots comme « call » ou « reach ».

Les poids sont assez petits pour vivre dans le dépôt, à côté de ce fichier,
et il n'y a aucun service à exploiter : le modèle se charge avec le
processus.
"""

import re
import unicodedata

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

# Digits written as words, and the characters used to stand in for digits.
DIGIT_WORDS = ("zero|one|two|three|four|five|six|seven|eight|nine|ten|"
               "zéro|un|deux|trois|quatre|cinq|six|sept|huit|neuf|dix")
LOOKALIKES = str.maketrans({"O": "0", "o": "0", "I": "1", "i": "1", "l": "1", "S": "5", "s": "5"})


def _is_digits_in_disguise(token: str) -> bool:
    """
    True when folding lookalike characters turns the whole token into digits.

    The token must already contain one real digit. Folding unconditionally
    would be a mistake: "loll" would fold to 1011 and read as a fragment of
    a phone number.
    """
    if not any(c.isdigit() for c in token):
        return False
    return token.translate(LOOKALIKES).isdigit()


def shape(text: str) -> str:
    """
    Turn a message into the features that matter, and drop the rest.

    Every hidden digit becomes the same symbol, whatever its spelling, so the
    classifier learns what a hidden number looks like rather than which digits
    it held.
    """
    out = []
    # Compatibility folding first: full-width digits become digits, and a
    # decomposed "zéro" becomes one word again.
    for token in re.findall(r"[^\W_]+", unicodedata.normalize("NFKC", text)):
        lowered = token.lower()
        # Case matters for lookalikes, so fold before lowering.
        if re.fullmatch(DIGIT_WORDS, lowered):
            out.append("D")
        elif _is_digits_in_disguise(token):
            out.append("D" * len(token))
        elif len(lowered) >= 2:
            out.append(lowered)
    return " ".join(out)


def train(messages: list[str], labels: list[int]):
    """`labels` is 1 when the message hides contact details, 0 when it does not."""
    model = make_pipeline(
        TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 4), min_df=1),
        LogisticRegression(class_weight="balanced", max_iter=1000),
    )
    model.fit([shape(m) for m in messages], labels)
    return model


def is_hiding_contact_details(model, message: str, threshold: float = 0.5) -> bool:
    """
    Returns a decision, and the threshold is yours to set.

    Move it towards 1 if a false positive means blocking a legitimate message.
    Move it towards 0 if letting one through is the worse outcome.
    """
    score = model.predict_proba([shape(message)])[0][1]
    return bool(score >= threshold)

JavaScript

snippets/mask-personal-data-in-chat/n1.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Rattraper les coordonnées obfusquées avec un classifieur léger.
 *
 * Niveau N1. Les expressions régulières du niveau N0 voient une façon d'écrire
 * un numéro. Celui-ci en voit la forme : une suite de jetons surtout faits de
 * chiffres, plutôt courts, posés à côté de mots comme « call » ou « reach ».
 *
 * Écrit en entier plutôt que tiré d'une bibliothèque : hacher les n-grammes de
 * caractères, entraîner et décider tiennent en trois courtes fonctions. C'est
 * tout l'argument de ce niveau : l'outil classique est assez petit pour être
 * lu.
 */

// Digits written as words, in English and in French.
const DIGIT_WORDS = new Set(
  ('zero one two three four five six seven eight nine ten ' +
    'zéro un deux trois quatre cinq six sept huit neuf dix').split(' '),
);

// Characters used to stand in for digits.
const LOOKALIKES = { O: '0', o: '0', I: '1', i: '1', l: '1', S: '5', s: '5' };

const BUCKETS = 512; // hashing trick: no vocabulary to build or ship

/**
 * True when folding lookalike characters turns the whole token into digits.
 * The token must already carry one real digit; folding unconditionally would
 * turn "loll" into 1011.
 */
function isDigitsInDisguise(token) {
  if (!/\d/.test(token)) return false;
  const folded = [...token].map((c) => LOOKALIKES[c] ?? c).join('');
  return /^\d+$/.test(folded);
}

/** Turn a message into the features that matter, and drop the rest. */
export function shape(text) {
  const out = [];
  // Compatibility folding first: full-width digits become digits, and a
  // decomposed "zéro" becomes one word again.
  for (const token of text.normalize('NFKC').match(/[\p{L}\p{N}]+/gu) ?? []) {
    const lowered = token.toLowerCase();
    if (DIGIT_WORDS.has(lowered)) out.push('D');
    else if (isDigitsInDisguise(token)) out.push('D'.repeat(token.length));
    else if (lowered.length >= 2) out.push(lowered);
  }
  return out.join(' ');
}

/** Character n-grams of the shaped message, hashed into a fixed vector. */
function features(text) {
  const shaped = ` ${shape(text)} `;
  const vector = new Float64Array(BUCKETS);
  for (let n = 2; n <= 4; n += 1) {
    for (let i = 0; i + n <= shaped.length; i += 1) {
      let h = 2166136261;
      for (const c of shaped.slice(i, i + n)) h = ((h ^ c.codePointAt(0)) * 16777619) >>> 0;
      vector[h % BUCKETS] += 1;
    }
  }
  const norm = Math.hypot(...vector);
  return norm ? vector.map((v) => v / norm) : vector;
}

/** `labels` is 1 when the message hides contact details, 0 when it does not. */
export function train(messages, labels, { epochs = 400, rate = 0.5 } = {}) {
  // An untrained model would score every message 0.5 and block them all.
  if (!labels.includes(0) || !labels.includes(1) || labels.length !== messages.length) {
    throw new RangeError('training needs messages of both labels, one label per message');
  }
  const rows = messages.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) {
      let z = bias;
      for (let j = 0; j < BUCKETS; j += 1) z += weights[j] * rows[i][j];
      const error = 1 / (1 + Math.exp(-z)) - labels[i];
      for (let j = 0; j < BUCKETS; j += 1) weights[j] -= rate * error * rows[i][j];
      bias -= rate * error;
    }
  }
  return { weights, bias };
}

/**
 * Returns a decision, and the threshold is yours to set.
 *
 * Move it towards 1 if a false positive means blocking a legitimate message.
 * Move it towards 0 if letting one through is the worse outcome.
 */
export function isHidingContactDetails(model, message, threshold = 0.5) {
  const vector = features(message);
  let z = model.bias;
  for (let j = 0; j < BUCKETS; j += 1) z += model.weights[j] * vector[j];
  return 1 / (1 + Math.exp(-z)) >= threshold;
}

Ce que ça implique

  • Vos données ne sortent pas
  • 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 de données personnelles sur votre infrastructure
  • Le corpus d’entraînement contient des messages réels : il entre dans votre registre des traitements si vous en tenez un

Point de rupture

Ce niveau ne masque rien : il rend un verdict sur le message entier, à bloquer ou à envoyer en modération. Et le modèle ne connaît que les contournements qu’on lui a montrés : des homoglyphes venus d’un autre alphabet, ou des chiffres romains, ne portent ni chiffre ni mot connu, et le message passe.

Quand passer au niveau suivant

Les contournements changent plus vite que vous ne pouvez étiqueter de nouveaux exemples.

N2Niveau sans objet ici

Niveau sans objet ici

Petit modèle spécialisé auto-hébergéN2

Sans intérêt pour ce besoin : un modèle de reconnaissance d’entités auto-hébergé ajoute des poids à télécharger et à tenir à jour, pour des motifs aussi structurés qu’un numéro, une adresse électronique ou un IBAN, que N0 masque déjà. Les numéros déguisés, que N0 laisse passer, sont le terrain de N1. C’est sur les noms de personne et les adresses postales qu’un tel modèle deviendrait le bon outil — et ils sont hors du périmètre de cette fiche, qui ne traite que les coordonnées.

N3Extraction structurée par appel à un modèle généralisteélevé · Une seconde

Extraction structurée par appel à un modèle généraliste

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

Python

snippets/mask-personal-data-in-chat/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.
"""
Masquer les coordonnées en interrogeant un modèle généraliste.

Niveau N3. Il est ici pour qu'on voie ce qu'il coûte, pas parce que cette
fiche le recommande.

Voyez ce que le code doit faire et que le niveau N0 n'avait pas à faire :
réessayer après un échec, plafonner la taille de l'entrée, analyser une
réponse qui n'est que probablement du JSON valide, en vérifier chaque élément
contre le message, et refuser de laisser passer le message non masqué quand
la réponse est inutilisable. Une invite n'est qu'une demande : rien en elle
n'oblige le modèle à suivre le format demandé. Cette plomberie est le vrai
coût de ce niveau, et c'est la part que vos tests doivent couvrir, puisque le
modèle lui-même n'est pas testable.
"""

from __future__ import annotations

import json

PROMPT = (
    "Find every piece of personal contact information in the message below.\n"
    "Answer with JSON only: a list of objects with keys `text` and `kind`,\n"
    "where `kind` is one of email, phone, iban, address.\n"
    "If there is none, answer with an empty list.\n\n"
    "Message:\n{message}"
)
KINDS = ("email", "phone", "iban", "address")

MAX_CHARACTERS = 8000

# 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


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


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


def mask(message: str, client=None, *, attempts: int = 3) -> str:
    """
    Replace contact details with a label naming what was removed.

    `client` is injected so this function can be tested without a network call.
    In production it defaults to a real provider client.
    """
    client = client or ProviderClient()

    # The provider bills every token of the prompt. Refusing oversized input
    # before the call is a cost control; the cap counts characters, not tokens.
    if len(message) > MAX_CHARACTERS:
        raise ValueError(f"message longer than {MAX_CHARACTERS} characters")

    found = _ask(client, message, attempts)

    # Replace the longest matches first, so a substring never eats its parent.
    for item in sorted(found, key=lambda i: len(i["text"]), reverse=True):
        message = message.replace(item["text"], f"[{item['kind']}]")
    return message


def _is_usable(parsed, message: str) -> bool:
    """A list of {text, kind}, every text found as is in the message, every kind one we asked for."""
    return isinstance(parsed, list) and all(
        isinstance(item, dict)
        and isinstance(item.get("text"), str)
        and item["text"] != ""
        and item["text"] in message
        and item.get("kind") in KINDS
        for item in parsed
    )


def _unfenced(answer: str) -> str:
    """
    Strip a code fence that wraps the whole answer, and nothing else.

    Models often hand back `\u0060\u0060\u0060json …\u0060\u0060\u0060`, and refusing that form would
    pay for a second call for nothing. Any other departure — text before or
    after, two blocks, a fence never closed — is left alone, and fails to
    parse, which is the point.
    """
    text = answer.strip()
    if text.startswith("```") and text.endswith("```") and text.count("```") == 2:
        return text[3:-3].removeprefix("json")
    return text


def _ask(client, message: str, attempts: int) -> list[dict]:
    last_error: Exception | None = None
    for _ in range(attempts):
        try:
            answer = client.complete(prompt=PROMPT.format(message=message), temperature=0)
            if answer is None:  # a refusal carries no content: unusable, not empty
                last_error = ValueError("the model returned no content")
                continue
            parsed = json.loads(_unfenced(answer))
            if _is_usable(parsed, message):
                return parsed
            # An item the message does not contain would mask nothing, and the message would leave in clear.
            last_error = ValueError("the model answered something other than items found in the message")
        except Exception as error:  # noqa: BLE001 - any provider failure is retried
            last_error = error
    raise MaskingUnavailable(str(last_error))

JavaScript

snippets/mask-personal-data-in-chat/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.
/**
 * Masquer les coordonnées en interrogeant un modèle généraliste.
 *
 * Niveau N3. Il est ici pour qu'on voie ce qu'il coûte, pas parce que cette
 * fiche le recommande.
 *
 * Voyez ce que le code doit faire et que le niveau N0 n'avait pas à faire :
 * réessayer après un échec, plafonner la taille de l'entrée, analyser une
 * réponse qui n'est que probablement du JSON valide, en vérifier chaque élément
 * contre le message, et refuser de laisser passer le message non masqué quand
 * la réponse est inutilisable. Une invite n'est qu'une demande : rien en elle
 * n'oblige le modèle à suivre le format demandé. Cette plomberie est le vrai
 * coût de ce niveau, et c'est la part que vos tests doivent couvrir, puisque le
 * modèle lui-même n'est pas testable.
 */

const PROMPT = [
  'Find every piece of personal contact information in the message below.',
  'Answer with JSON only: a list of objects with keys `text` and `kind`,',
  'where `kind` is one of email, phone, iban, address.',
  'If there is none, answer with an empty list.',
  '',
  'Message:',
].join('\n');
const KINDS = ['email', 'phone', 'iban', 'address'];

export const MAX_CHARACTERS = 8000;

// 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 class MaskingUnavailable extends Error {}

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;
    },
  };
}

/**
 * Replace contact details with a label naming what was removed.
 *
 * @param {string} message
 * @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 {number} [options.attempts]
 */
export async function mask(message, { client, attempts = 3 } = {}) {
  client ??= await providerClient();

  // The provider bills every token of the prompt. Refusing oversized input
  // before the call is a cost control; the cap counts characters (code points,
  // as Python does), not tokens.
  if ([...message].length > MAX_CHARACTERS) {
    throw new RangeError(`message longer than ${MAX_CHARACTERS} characters`);
  }

  const found = await ask(client, message, attempts);

  // Replace the longest matches first, so a substring never eats its parent.
  let out = message;
  for (const { text, kind } of [...found].sort((a, b) => b.text.length - a.text.length)) {
    out = out.split(text).join(`[${kind}]`);
  }
  return out;
}

/** A list of {text, kind}, every text found as is in the message, every kind one we asked for. */
function isUsable(parsed, message) {
  return Array.isArray(parsed) && parsed.every((item) => (
    typeof item?.text === 'string' && item.text !== '' && message.includes(item.text) && KINDS.includes(item.kind)
  ));
}

/**
 * Strip a code fence that wraps the whole answer, and nothing else.
 *
 * Models often hand back ```json … ```, and refusing that form would pay for a
 * second call for nothing. Any other departure — text before or after, two
 * blocks, a fence never closed — is left alone, and fails to parse, which is
 * the point.
 */
function unfenced(answer) {
  const text = answer.trim();
  const fences = text.match(/```/g)?.length ?? 0;
  if (text.startsWith('```') && text.endsWith('```') && fences === 2) {
    return text.slice(3, -3).replace(/^json/, '');
  }
  return text;
}

async function ask(client, message, attempts) {
  let lastError;
  for (let i = 0; i < attempts; i += 1) {
    try {
      const answer = await client.complete({
        prompt: `${PROMPT}\n${message}`,
        // Temperature zero, because a masking decision that changes between
        // two identical calls cannot be reviewed.
        temperature: 0,
      });
      if (answer === null || answer === undefined) {
        // A refusal carries no content: unusable, not empty.
        lastError = new Error('the model returned no content');
        continue;
      }
      const parsed = JSON.parse(unfenced(answer));
      // An item the message does not contain would mask nothing, and the message would leave in clear.
      if (isUsable(parsed, message)) return parsed;
      lastError = new Error('the model answered something other than items found in the message');
    } catch (error) {
      lastError = error;
    }
  }
  throw new MaskingUnavailable(String(lastError));
}

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 de données personnelles à un sous-traitant, avec l’encadrement contractuel que cela suppose
  • Localisation du traitement à vérifier auprès du fournisseur
  • Ne vous dispense pas de vos propres obligations d’information et de minimisation

Point de rupture

Une invite n’est qu’une demande : rien dans la requête n’oblige la réponse à suivre le format voulu. De la prose, une liste aux mauvaises clés, un texte qui ne figure pas dans le message, une étiquette hors de la liste : le comportement dangereux serait de hausser les épaules et de renvoyer le message tel quel, ce qui laisserait fuir exactement ce qu’on voulait retirer. L’extrait vérifie chaque élément, réessaie, puis lève une erreur, et c’est à l’appelant de décider.

Quand passer au niveau suivant

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

Pour aller plus loin

Source des chiffres

Métadonnées