Do you really need AI for that?

For every common task, all the known options, from a rule to a general-purpose model. With code that runs, orders of magnitude, and the precise condition that justifies moving up a rung.

Where to startBrowse the catalogue

Overkill is not anti-AI. Some entries recommend a general-purpose model without hedging, and those are what make the entries recommending the opposite worth believing.

Three ways in

  • You don’t know what to use

    Answer eight questions

    You know your need, not the solution. The questionnaire tells you what is enough, why, and which entry to read. Two minutes, no technical knowledge.

    Where to start

  • You are after a specific task

    50 entries, 38 of them with no AI at all

    Every entry shows the options from lightest to heaviest, with code that runs, what it costs, and what makes it give way.

    Open the catalogue

  • You want to understand the scale

    Four levels, one condition to climb

    From a rule to a general-purpose model. What each level guarantees, what it costs, and the precise condition that makes the level below insufficient.

    How it works

The same task, twice

Masking a phone number in a message

N3What you get offered83 lines

A general-purpose model call on every inbound message.

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.
See the remaining 71 lines
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))

N0What is enough85 lines

One normalisation, then three regular expressions.

"""
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
See the remaining 77 lines
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))
    )
Masking a phone number in a message
LevelN3N0
Costhighnone
Latency~1 s<1 ms
Datagoes to a third partynothing leaves
Deterministicnoyes
Testablehardlyunit

The heaviest option is not always the wrong choice. On this particular task, it is.

And this code runs

On the entry, this code runs in your browser: you type your own text, and you see what it catches and what it leaves.

The four levels

Always in the same order, from lightest to heaviest. The scale does not measure how good a solution is, it measures its weight: what you have to start up to get the result.

  1. N0

    Rule and classic algorithm

    A regular expression, a query, a state machine. The code does exactly what it says, and you can read all of it.

  2. N1

    Lightweight classic model

    A model trained on your data, small enough to be versioned next to the code that uses it, running on an ordinary processor.

  3. N2

    Small self-hosted specialised model

    A specialised model running on your machines. It weighs a lot, it does one thing, and nothing leaves your infrastructure.

  4. N3

    General-purpose LLM API

    A call to an outside provider. The model does everything roughly and nothing exactly, and it is not yours.

What each one costs, and when it gives way

This catalogue is yours

Fifty entries are written, and the catalogue aims at two hundred. If you have seen someone reach for a general-purpose model where a rule would have done, an entry is missing.