Mask contact details in a chat message

Prevent a phone number, an email address or an IBAN from appearing in a sent message. Not a name and not a postal address: those are personal data too, and no level on this page touches them.

No AI needed

FreeInstantYour data never leaves

The verdict

N0: the need is deterministic, the marginal cost is nil, nothing leaves your infrastructure, and the result is unit testable, which matters when a false negative publishes somebody’s phone number. N1 does not replace N0, it sits beside it: it masks nothing, it flags a whole message to block or to send for review. Add it the day you actually see deliberate evasion, not before: you would take on a training set to maintain, and your users would see their message refused instead of published with a number cut out of it. As for N3, it hands the whole message to a provider in order to take personal data out of it: that is the first reason not to pick it.

Normalisation then regular expressions

No AI needed

Rule and classic algorithmN0FreeInstant

Python

snippets/mask-personal-data-in-chat/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
Mask contact details in a chat message: normalisation, then regular
expressions.

Rung N0. Deterministic, standard library only. Contact details, and those
only: a phone number, an email address, an IBAN. A name, a postal address or a
date of birth are personal data too, and nothing here touches them.

Two things make this work.

First, the patterns read a normalised copy of the message, where every
character is written in its plain form: compatibility folding brings full-width
digits back to ordinary ones, and the spaces of French typography, the
zero-width characters and the soft hyphen become a plain space. The labels are
then put into the message as it was written, so a message with nothing to mask
comes back unchanged, apart from the composition of its accents (NFC), which
changes nothing on screen.

Second, the phone patterns tolerate the separators people type between digits
— a space, a dot, a dash or a slash, one or two of them — and an IBAN is only
masked when its check digits add up.
"""

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.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * Mask contact details in a chat message: normalisation, then regular
 * expressions.
 *
 * Rung N0. Deterministic, no dependency. Contact details, and those only: a
 * phone number, an email address, an IBAN. A name, a postal address or a date
 * of birth are personal data too, and nothing here touches them.
 *
 * Two things make this work.
 *
 * First, the patterns read a normalised copy of the message, where every
 * character is written in its plain form: compatibility folding brings
 * full-width digits back to ordinary ones, and the spaces of French
 * typography, the zero-width characters and the soft hyphen become a plain
 * space. The labels are then put into the message as it was written, so a
 * message with nothing to mask comes back unchanged, apart from the
 * composition of its accents (NFC), which changes nothing on screen.
 *
 * Second, the phone patterns tolerate the separators people type between
 * digits — a space, a dot, a dash or a slash, one or two of them — and an IBAN
 * is only masked when its check digits add up.
 */

// 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('');
}

Try it

interactive

The code above runs in your browser. Type in the box: the result is redone in front of you.

yours to edit

Recomputed on every keystroke.

Or start from an example

Free typing and the other examples need JavaScript. The example shown below was computed when the site was built.

What the code makes of it

2 matches

What the code picked up

Hello, call me on 06 12 34 56 78 or write to marie.durand@example.com

What it returns

Hello, call me on [phone] or write to [email]

What this means for you

  • Your data stays put
  • Always the same result
  • Unit-testable, case by case
  • No provider in the loop
  • Negligible footprint

What this adds to your regulatory scope

  • No specific scope added: the message never leaves your infrastructure

Breaking point

Deliberate obfuscation: “jean [at] example [dot] com”, lookalike characters such as O6 I2 34, spelled-out digits. None of it looks like a contact detail to a pattern. The other way round, a ten-digit order reference is masked as “[phone]”, and a coincidental IBAN — DE 86 3456 7890 1234 passes the modulo 97 check — as “[iban]”.

When to move up a level

Your users are actively working around the filter, and you can see it in the reported messages.

The four options

  1. RecommendedN0Normalisation then regular expressionsFree · Instant
  2. N1Spotting a message with disguised contact details, by logistic regression on token shapenégligeable · Instant
  3. N2Not worth it here
  4. N3Structured extraction through a general-purpose modelélevé · About a second

The other options

N1Spotting a message with disguised contact details, by logistic regression on token shapenégligeable · Instant

Spotting a message with disguised contact details, by logistic regression on token shape

Lightweight classic modelN1négligeableInstant

Python

snippets/mask-personal-data-in-chat/n1.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
Catch obfuscated contact details with a light classifier.

Rung N1. The regular expressions of N0 see one spelling of a phone number.
This sees the shape of one: a run of tokens that is mostly digits, mostly
short, and sitting next to words like "call" or "reach".

The weights are small enough to keep in the repository next to this file, and
there is no service to run: the model loads with the process.
"""

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.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * Catch obfuscated contact details with a light classifier.
 *
 * Rung N1. The regular expressions of N0 see one spelling of a phone number.
 * This sees the shape of one: a run of tokens that is mostly digits, mostly
 * short, sitting next to words like "call" or "reach".
 *
 * Written out in full rather than pulled from a library: hashing the character
 * n-grams, training and deciding take three short functions. That is the
 * whole argument of this rung: the classical tool is small enough to read.
 */

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

What this means for you

  • Your data stays put
  • Always the same result
  • Measured on a sample, not case by case
  • Depends on a library
  • Low footprint

What this adds to your regulatory scope

  • Processing of personal data on your own infrastructure
  • The training corpus holds real messages: it belongs in your record of processing activities, where you keep one

Breaking point

This level masks nothing: it returns a verdict on the whole message, to block or to send for review. And the model only knows the evasions it was shown: homoglyphs from another script, or Roman numerals, carry neither a digit nor a known word, and the message goes through.

When to move up a level

Evasions change faster than you can label new examples.

N2Not worth it here

Not worth it here

Small self-hosted specialised modelN2

Not useful for this need: a self-hosted entity recogniser adds weights to download and keep up to date, for shapes as structured as a phone number, an email address or an IBAN, which N0 already masks. Disguised numbers, which N0 lets through, are N1’s ground. Where such a model would become the right tool is on people’s names and postal addresses — and those are outside the scope of this page, which handles contact details only.

N3Structured extraction through a general-purpose modelélevé · About a second

Structured extraction through a general-purpose model

General-purpose LLM APIN3élevéAbout a second

Python

snippets/mask-personal-data-in-chat/n3.pyTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
"""
Mask personal data by asking a general-purpose model.

Rung N3. It is here so you can see what it costs, not because this entry
recommends it.

Note what the code has to do that N0 did not: retry on failure, cap the input
size, parse an answer that is only probably valid JSON, check every item of it
against the message, and refuse to pass the message through unmasked when the
answer is unusable. A prompt is only a request: nothing in it holds the model to
the format asked for. That plumbing is the real cost of this rung, and it is the
part your tests have to cover, because the model itself is not 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.jsTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
/**
 * Mask personal data by asking a general-purpose model.
 *
 * Rung N3. It is here so you can see what it costs, not because this entry
 * recommends it.
 *
 * Note what the code has to do that N0 did not: retry on failure, cap the
 * input size, parse an answer that is only probably valid JSON, check every
 * item of it against the message, and refuse to pass the message through
 * unmasked when the answer is unusable. A prompt is only a request: nothing in
 * it holds the model to the format asked for. That plumbing is the real cost
 * of this rung, and it is the part your tests have to cover, because the model
 * itself is not 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));
}

What this means for you

  • Your data goes to a third party
  • The result varies from one call to the next
  • Hard to test
  • Depends on an outside provider
  • High footprint

What this adds to your regulatory scope

  • Transfer of personal data to a processor, with the contractual framing that implies
  • Processing location to be confirmed with the provider
  • Does not excuse you from your own duties of transparency and minimisation

Breaking point

A prompt is only a request: nothing in the call holds the answer to the format asked for. Prose, a list with the wrong keys, a text the message does not contain, a label outside the list: the dangerous behaviour would be to shrug and return the message unmasked, leaking precisely what you meant to remove. The snippet checks every item, retries, then raises, and the caller decides.

When to move up a level

There is no level above this one.

Further reading

Where the figures come from

Metadata