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.

Par où commencerVoir le catalogue

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.

    Par où commencer

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

    Ouvrir le catalogue

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

    Comment ça marche

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))
    )
Masquer un numéro de téléphone dans un message
NiveauN3N0
Coûtélevénul
Latence~1 s<1 ms
Donnéespartent chez un tiersrien ne sort
Déterministenonoui
Testabledifficilementunitairement

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.

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.

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

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

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

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

Ce que chacun coûte, et quand il cède

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.