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
"""
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
/**
* 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
interactifLe code ci-dessus tourne dans votre navigateur. Écrivez dans le champ : le résultat se refait sous vos yeux.
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.
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]
Là, ça casseVoir le niveau au-dessus
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
- RecommandéN0Normalisation puis expressions régulièresGratuit · Instantané
- N1Détection d’un message à coordonnées déguisées, par régression logistique sur la forme des jetonsnégligeable · Instantané
- N2Niveau sans objet ici
- 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
"""
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
/**
* 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
"""
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
/**
* 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
- Python — module re, expressions régulières
- Unicode Technical Standard 39 — Unicode Security Mechanisms, la détection des caractères sosies
- MDN — Expressions régulières en JavaScript
Source des chiffres
- RFC 2822 (2001) et RFC 5322 (2008), section 3.4.1 — la même définition d’addr-spec
- Wikipédia — Plan de numérotation en France : numérotation à dix chiffres depuis le 18 octobre 1996
- Wikipedia — International Bank Account Number : longueur, groupes de quatre, clé modulo 97
- Unicode Standard Annex 15 — Unicode Normalization Forms : équivalence canonique et de compatibilité
- OpenAI — tarifs de l’API, exprimés par million de jetons
Métadonnées
Information générale, tenue à jour au mieux. Ce n’est pas un avis juridique. Faites valider votre situation par un professionnel.