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.
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.
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.
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.
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 everySee 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))
)| Level | 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 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.
Ten families, sorted by verb
- Detect and filterHow do I catch what should not get through?
- ExtractHow do I get that value out of the text?
- Classify and routeWhere should this message go?
- SearchHow do I find the right thing in all this?
- RecommendWhat do I show next?
- PredictWhat is going to happen, and what looks wrong?
- GenerateHow do I produce this without writing it by hand?
- TransformHow do I put this in another form?
- Recognise and transcribeHow do I read what is not text yet?
- Decide and validateDo I accept this, and on what grounds?
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.
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.
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.
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.
N3
General-purpose LLM API
A call to an outside provider. The model does everything roughly and nothing exactly, and it is not yours.
Three entries to start with
Chosen for the spread of their verdicts, including the one that recommends calling a model.
- Mask contact details in a chat messagePrevent 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 neededFreeInstantYour data never leaves
- Route a support ticket to the right teamSend each incoming support ticket to the team that can answer it.No generative AI needednégligeableInstantYour data stays with you
- Write product descriptionsWrite the sales text for every item in a catalogue, starting from its characteristics.Yes, a general-purpose model earns its placeélevéAbout a secondYour data goes to a third party
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.