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.
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| 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.
Ten families, sorted by verb
- Detect and filter How do I catch what should not get through?
- Extract How do I get that value out of the text?
- Classify and route Where should this message go?
- Search How do I find the right thing in all this?
- Recommend What do I show next?
- Predict What is going to happen, and what looks wrong?
- Generate How do I produce this without writing it by hand?
- Transform How do I put this in another form?
- Recognise and transcribe How do I read what is not text yet?
- Decide and validate Do I accept this, and on what grounds?
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.
-
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 personal data in a chat thread RecommendedN0
- Route a support ticket to the right team RecommendedN1
- Write product descriptions RecommendedN3
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.