Avez-vous vraiment besoin d'IA pour ça ?
Pour chaque tâche courante, toutes les options connues, de la règle au modèle généraliste. Avec le code qui tourne, les ordres de grandeur, et la condition précise qui justifie de monter d'un cran.
Overkill n'est pas anti-IA. Certaines fiches recommandent franchement un modèle généraliste, et ce sont elles qui rendent crédibles celles qui recommandent l'inverse.
Trois façons d’entrer
Vous ne savez pas quoi employer
Répondez à huit questions
Vous connaissez votre besoin, pas la solution. Le questionnaire vous dit ce qui suffit, pourquoi, et vers quelle fiche aller. Deux minutes, aucune connaissance technique.
Vous cherchez une tâche précise
50 fiches, dont 38 sans aucune IA
Chaque fiche montre les options de la plus légère à la plus lourde, avec le code qui tourne, ce qu’il coûte, et ce qui le fait céder.
Vous voulez comprendre l’échelle
Quatre niveaux, une condition pour monter
De la règle au modèle généraliste. Ce que chaque niveau garantit, ce qu’il coûte, et la condition précise qui rend le niveau du dessous insuffisant.
La même tâche, deux fois
Masquer un numéro de téléphone dans un message
N3Ce qu’on vous propose83 lignes
Un appel de modèle généraliste sur chaque message entrant.
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.Voir les 71 lignes restantes
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))N0Ce qui suffit87 lignes
Une normalisation, puis trois expressions régulières.
"""
Masquer les coordonnées dans un message : normalisation, puis expressions
régulières.
Niveau N0. Déterministe, bibliothèque standard seule. Les coordonnées, et
elles seules : un numéro de téléphone, une adresse électronique, un IBAN. Un
nom, une adresse postale ou une date de naissance sont aussi des données
personnelles, et rien ici n'y touche.
Deux choses font que ça marche.Voir les 79 lignes restantes
D'abord, les motifs lisent une copie normalisée du message, où chaque
caractère est écrit sous sa forme ordinaire : le repli de compatibilité
ramène les chiffres pleine largeur à des chiffres ordinaires, et les espaces
de la typographie française, les caractères sans chasse et le trait d'union
conditionnel deviennent une espace simple. Les étiquettes sont ensuite posées
dans le message tel qu'il a été écrit : un message sans rien à masquer
ressort inchangé, à la composition de ses accents près (NFC), qui ne change
rien à l'écran.
Ensuite, les motifs de téléphone tolèrent les séparateurs que les gens tapent
entre les chiffres — espace, point, tiret ou barre oblique, un ou deux — et un
IBAN n'est masqué que si sa clé de contrôle tombe juste.
"""
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))
)| Niveau | N3 | N0 |
|---|---|---|
| Coût | élevé | nul |
| Latence | ~1 s | <1 ms |
| Données | partent chez un tiers | rien ne sort |
| Déterministe | non | oui |
| Testable | difficilement | unitairement |
L’option la plus lourde n’est pas toujours le mauvais choix. Sur cette tâche-ci, elle l’est.
Et ce code tourne
Sur la fiche, ce code s’exécute dans votre navigateur : vous tapez votre texte, et vous voyez ce qu’il attrape et ce qu’il laisse.
Dix familles, rangées par verbe
- Détecter et filtrerComment repérer ce qui ne doit pas passer ?
- ExtraireComment sortir cette information du texte ?
- Classer et routerOù doit aller ce message ?
- ChercherComment retrouver la bonne chose dans tout ça ?
- RecommanderQu’est-ce que je propose ensuite ?
- PrédireQue va-t-il se passer, et qu’est-ce qui cloche ?
- GénérerComment produire ça sans l’écrire à la main ?
- TransformerComment rendre ça sous une autre forme ?
- Reconnaître et transcrireComment lire ce qui n’est pas déjà du texte ?
- Décider et validerEst-ce que j’accepte ceci, et sur quel fondement ?
Les quatre niveaux
Toujours dans le même ordre, du plus léger au plus lourd. L'échelle ne mesure pas la qualité d'une solution, elle mesure son poids : ce qu'il faut mettre en marche pour obtenir le résultat.
N0
Règle et algorithme classique
Une expression régulière, une requête, un automate. Le code fait exactement ce qu’il dit, et vous pouvez le lire en entier.
N1
Modèle classique léger
Un modèle entraîné sur vos données, assez petit pour être versionné à côté du code qui s’en sert, et qui tourne sur un processeur ordinaire.
N2
Petit modèle spécialisé auto-hébergé
Un modèle spécialisé qui tourne chez vous. Il pèse lourd, il fait une chose, et rien ne sort de votre infrastructure.
N3
API de LLM généraliste
Un appel à un fournisseur externe. Le modèle sait tout faire à peu près, rien exactement, et il ne vous appartient pas.
Trois fiches pour commencer
Choisies pour la diversité de leurs verdicts, y compris celui qui recommande d'appeler un modèle.
- Masquer les coordonnées dans un messageEmpêcher qu’un numéro de téléphone, une adresse électronique ou un IBAN soit visible dans un message envoyé. Ni un nom ni une adresse postale : ce sont aussi des données personnelles, et aucun niveau de cette fiche ne les touche.Pas besoin d’IAGratuitInstantanéVos données ne sortent pas
- Router un ticket de support vers la bonne équipeEnvoyer chaque ticket de support entrant à l’équipe capable d’y répondre.Pas besoin d’IA générativenégligeableInstantanéVos données restent chez vous
- Rédiger des descriptions produitÉcrire le texte de présentation de chaque article d’un catalogue à partir de ses caractéristiques.Oui, un modèle généraliste se justifieélevéUne secondeVos données partent chez un tiers
Ce catalogue vous appartient
Cinquante fiches sont écrites, et le catalogue en visera deux cents. Si vous avez vu quelqu'un employer un modèle généraliste là où une règle aurait suffi, il manque une fiche.