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.

Browse 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.

The same task, twice

Masking a phone number in a message

N3 What you get offered 42 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}"
)

MAX_CHARACTERS = 8000


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


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.
    """
    if client is None:  # pragma: no cover - needs a key and a network
        from openai import OpenAI

        client = OpenAI()

    # A model charges by the token. Refusing oversized input is not an
    # optimisation, it is a cost control.
    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.get("text", "")), reverse=True):
        text, kind = item.get("text"), item.get("kind")
        if text and kind:
            message = message.replace(text, f"[{kind}]")
    return message


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)
            parsed = json.loads(answer)
            if isinstance(parsed, list):
                return parsed
            last_error = ValueError("the model answered something that is not a list")
        except Exception as error:  # noqa: BLE001 - any provider failure is retried
            last_error = error
    raise MaskingUnavailable(str(last_error))

N0 What is enough 25 lines

One normalisation, then three regular expressions.

UNUSUAL_SPACES = re.compile(r"[     ]")

EMAIL = re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+")

# French numbers: 0X XX XX XX XX, or +33 X XX XX XX XX. The separator between
# digits may be a space, a dot or a dash, or absent.
SEP = r"[ .-]?"
PHONE = re.compile(rf"(?<![\d+]){SEP}(?:\+{SEP}33{SEP}|0)[1-9](?:{SEP}\d){{8}}(?!\d)")

# IBAN: two letters, two check digits, then up to thirty alphanumerics,
# conventionally grouped in fours.
IBAN = re.compile(r"(?<![A-Z0-9])[A-Z]{2} ?\d{2}(?: ?[A-Z0-9]){10,28}(?![A-Z0-9])")

# Order matters: an email may contain digits that would otherwise be read as
# the start of a phone number.
PATTERNS = ((EMAIL, "[email]"), (IBAN, "[iban]"), (PHONE, "[phone]"))


def normalise(text: str) -> str:
    """Reduce the many spellings of a space to a plain one."""
    return UNUSUAL_SPACES.sub(" ", unicodedata.normalize("NFKC", text))


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 = normalise(text)
    for pattern, label in PATTERNS:
        text = pattern.sub(label, text)
    return text
Masking a phone number in a message
Rung N3 N0
Cost high none
Latency ~1 s <1 ms
Data goes to a third party nothing leaves
Deterministic no yes
Testable hardly unit

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

How to read an entry

Four rungs, always in the same order. The ladder does not measure how good a solution is, it measures its weight: what you have to start up to get the result.

  1. Rule and classic algorithm 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. Lightweight classic model 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. Small self-hosted specialised model 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. General-purpose LLM API N3

    General-purpose LLM API

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

How we judge

This catalogue is yours

Two hundred needs are on the roadmap, twenty-five are written. If you have seen someone reach for a general-purpose model where a rule would have done, an entry is missing.