Détecter la langue d’un texte

Savoir dans quelle langue un message est écrit avant de le traiter ou de l’envoyer au bon interlocuteur.

Pas besoin d’IA

GratuitInstantanéVos données ne sortent pas

Le verdict

N0 l’emporte sur le message qui porte deux langues, le cas le plus fréquent d’une file de support : son écart se resserre quand les deux moitiés se brouillent, quand la probabilité de N1 fait l’inverse et monte à la quasi-certitude sur ce même message. Sur le mot isolé, en revanche, aucun des deux ne prévient : « chat » ressort en anglais en N0 avec un écart large, et en anglais en N1 au-dessus du seuil ; c’est un échec commun aux deux niveaux, que ni l’écart ni la probabilité ne signalent, et la seule parade est de savoir que vos textes sont courts. Montez à N1 le jour où vous voulez un seuil exprimé en probabilité plutôt qu’en écart de rang, en sachant qu’il s’abstient sur « ça va » et qu’il ne connaît que les langues apprises. Montez à N2 le jour où vous devez reconnaître une langue dont vous n’avez pas d’échantillon : couvrir une centaine de langues est l’affaire d’un identifiant dédié local, embarqué avec son modèle, pas d’un appel de modèle généraliste.

Profils de trigrammes de caractères, distance de rang

Pas besoin d’IA

Règle et algorithme classiqueN0GratuitInstantané

Python

snippets/detect-language-of-text/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Détecter la langue d'un texte : profils de trigrammes de caractères,
distance de rang.

Niveau N0. Déterministe, sans dépendance, et le modèle entier tient en
quelques centaines de chaînes courtes par langue.

L'idée vient de Cavnar et Trenkle, en 1994. Chaque langue emploie certains
trigrammes bien plus que d'autres : « ent », « les », « eur » en français,
« the », « ing » en anglais, « que », « los » en espagnol. Classez-les par
fréquence dans un échantillon de la langue, classez-les de nouveau dans le
texte à identifier, puis comparez les deux ordres. La langue dont l'ordre
est le plus proche gagne.

Deux détails font que ça marche.

D'abord, les mots sont entourés d'espaces avant d'être découpés : un
trigramme porte ainsi l'information qu'il ouvre ou ferme un mot. « les » au
milieu d'un mot n'est pas l'article.

Ensuite, la comparaison porte sur des rangs et non sur des comptes bruts. Un
compte grandit avec la longueur du texte, un rang non : le même texte répété
quatre fois garde tous ses rangs. Et la distance est divisée par le nombre de
trigrammes, si bien qu'un texte long et un texte court tombent sur la même
échelle.

Un paragraphe par langue suffit à voir la méthode fonctionner. Il ne suffit
pas à déployer : les profils ne valent que l'échantillon dont on les tire, et
un paragraphe laisse de côté l'essentiel d'une langue. Cavnar et Trenkle ont
bâti les leurs sur des corpus. Prenez plusieurs pages.
"""

import re
import unicodedata
from collections import Counter

PROFILE_SIZE = 300

# Letters only. Digits, punctuation and symbols say nothing about a language,
# and a text full of them would drown the trigrams that do.
WORDS = re.compile(r"[^\W\d_]+")


def trigrams(text: str):
    """Yield the padded trigrams of every word, in reading order."""
    lowered = unicodedata.normalize("NFC", text.lower())
    for word in WORDS.findall(lowered):
        padded = f" {word} "
        for i in range(len(padded) - 2):
            yield padded[i:i + 3]


def profile(sample: str, size: int = PROFILE_SIZE) -> dict[str, int]:
    """
    Build a language profile from a sample: trigram to rank, most frequent
    first.

    Ties are broken alphabetically, so the same sample always gives the same
    profile. A language model you cannot reproduce is a language model you
    cannot debug.
    """
    counts = Counter(trigrams(sample))
    ordered = sorted(counts.items(), key=lambda item: (-item[1], item[0]))
    return {gram: rank for rank, (gram, _) in enumerate(ordered[:size])}


def distance(text: str, reference: dict[str, int], size: int = PROFILE_SIZE) -> float:
    """
    Out-of-place distance between the text and one language profile.

    Every trigram of the text costs how far it moved in the ranking. A
    trigram the language never uses costs the maximum, which is what makes an
    unrelated language expensive rather than merely different.

    Dividing by the number of trigrams keeps a long text and a short one on
    the same scale.
    """
    text_profile = profile(text, size)
    if not text_profile:
        return float(size)
    total = 0
    for gram, rank in text_profile.items():
        reference_rank = reference.get(gram)
        total += size if reference_rank is None else abs(rank - reference_rank)
    return total / len(text_profile)


def ranked(text: str, profiles: dict[str, dict[str, int]]) -> list[tuple[str, float]]:
    """
    Every candidate language, closest first, each with its distance.

    The distances are on a fixed scale: `PROFILE_SIZE` at worst, when the
    language shares no trigram with the text. The gap between the first two is
    read against that scale — but read it for what it is, how far the
    runner-up finished behind, not how right the winner is. A single word can
    win by half the scale and still be the wrong language.
    """
    scores = [(name, distance(text, reference)) for name, reference in profiles.items()]
    return sorted(scores, key=lambda item: (item[1], item[0]))


def detect(text: str, profiles: dict[str, dict[str, int]]) -> str:
    """The closest language. It always returns one, even when it should not."""
    return ranked(text, profiles)[0][0]

JavaScript

snippets/detect-language-of-text/n0.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Détecter la langue d'un texte : profils de trigrammes de caractères,
 * distance de rang.
 *
 * Niveau N0. Déterministe, sans dépendance, et le modèle entier tient en
 * quelques centaines de chaînes courtes par langue.
 *
 * L'idée vient de Cavnar et Trenkle, en 1994. Chaque langue emploie certains
 * trigrammes bien plus que d'autres : « ent », « les », « eur » en français,
 * « the », « ing » en anglais, « que », « los » en espagnol. Classez-les par
 * fréquence dans un échantillon de la langue, classez-les de nouveau dans le
 * texte à identifier, puis comparez les deux ordres. La langue dont l'ordre
 * est le plus proche gagne.
 *
 * Deux détails font que ça marche.
 *
 * D'abord, les mots sont entourés d'espaces avant d'être découpés : un
 * trigramme porte ainsi l'information qu'il ouvre ou ferme un mot. « les » au
 * milieu d'un mot n'est pas l'article.
 *
 * Ensuite, la comparaison porte sur des rangs et non sur des comptes bruts. Un
 * compte grandit avec la longueur du texte, un rang non : le même texte répété
 * quatre fois garde tous ses rangs. Et la distance est divisée par le nombre de
 * trigrammes, si bien qu'un texte long et un texte court tombent sur la même
 * échelle.
 *
 * Un paragraphe par langue suffit à voir la méthode fonctionner. Il ne suffit
 * pas à déployer : les profils ne valent que l'échantillon dont on les tire, et
 * un paragraphe laisse de côté l'essentiel d'une langue. Cavnar et Trenkle ont
 * bâti les leurs sur des corpus. Prenez plusieurs pages.
 */

export const PROFILE_SIZE = 300;

// Letters only. Digits, punctuation and symbols say nothing about a language,
// and a text full of them would drown the trigrams that do.
const WORDS = /\p{L}+/gu;

/** The padded trigrams of every word, in reading order. */
export function trigrams(text) {
  const grams = [];
  for (const word of text.normalize('NFC').toLowerCase().match(WORDS) ?? []) {
    const padded = ` ${word} `;
    for (let i = 0; i + 3 <= padded.length; i += 1) grams.push(padded.slice(i, i + 3));
  }
  return grams;
}

/**
 * Build a language profile from a sample: trigram to rank, most frequent
 * first.
 *
 * Ties are broken alphabetically, so the same sample always gives the same
 * profile. A language model you cannot reproduce is a language model you
 * cannot debug.
 */
export function profile(sample, size = PROFILE_SIZE) {
  const counts = new Map();
  for (const gram of trigrams(sample)) counts.set(gram, (counts.get(gram) ?? 0) + 1);
  const ordered = [...counts].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
  return new Map(ordered.slice(0, size).map(([gram], rank) => [gram, rank]));
}

/**
 * Out-of-place distance between the text and one language profile.
 *
 * Every trigram of the text costs how far it moved in the ranking. A trigram
 * the language never uses costs the maximum, which is what makes an unrelated
 * language expensive rather than merely different.
 *
 * Dividing by the number of trigrams keeps a long text and a short one on the
 * same scale.
 */
export function distance(text, reference, size = PROFILE_SIZE) {
  const textProfile = profile(text, size);
  if (textProfile.size === 0) return size;
  let total = 0;
  for (const [gram, rank] of textProfile) {
    const referenceRank = reference.get(gram);
    total += referenceRank === undefined ? size : Math.abs(rank - referenceRank);
  }
  return total / textProfile.size;
}

/**
 * Every candidate language, closest first, each with its distance.
 *
 * The distances are on a fixed scale: `PROFILE_SIZE` at worst, when the
 * language shares no trigram with the text. The gap between the first two is
 * read against that scale — but read it for what it is, how far the runner-up
 * finished behind, not how right the winner is. A single word can win by half
 * the scale and still be the wrong language.
 *
 * @param {string} text
 * @param {Map<string, Map<string, number>>} profiles
 */
export function ranked(text, profiles) {
  const scores = [...profiles].map(([name, reference]) => [name, distance(text, reference)]);
  return scores.sort((a, b) => a[1] - b[1] || (a[0] < b[0] ? -1 : 1));
}

/** The closest language. It always returns one, even when it should not. */
export function detect(text, profiles) {
  return ranked(text, profiles)[0][0];
}

Essayer

interactif

Le code ci-dessus tourne dans votre navigateur. Écrivez dans le champ : le résultat se refait sous vos yeux.

Le modèle est fait de trois paragraphes, un par langue. Une distance basse dit que le texte emploie les mêmes trigrammes que l’échantillon ; l’écart dit de combien la deuxième a manqué, et rien d’autre : sur un mot isolé, il peut être large et la réponse fausse.

à vous d’écrire

Recalculé à chaque frappe.

Ou partez d’un exemple

La saisie libre et les autres exemples ont besoin de JavaScript. L’exemple affiché ci-dessous, lui, a été calculé à la construction du site.

Ce que le code en fait

Ce qu’il renvoie

français

12,1 d’écart avec espagnol, sur une échelle qui va à 300.

Le détail

LangueDistance (0 à 300)Écart avec la première
français222,6—
espagnol234,712,1
anglais255,132,6

Ce que ça implique

  • Vos données ne sortent pas
  • Résultat toujours identique
  • Se teste unitairement, cas par cas
  • Aucun fournisseur dans la boucle
  • Empreinte négligeable

Ce que cela ajoute à votre périmètre réglementaire

  • Aucun périmètre spécifique ajouté : le texte ne quitte pas votre infrastructure

Point de rupture

Les textes très courts, et ceux qui contiennent deux langues. « chat » ressort en anglais : de ses quatre trigrammes, les deux derniers figurent dans le profil anglais, qui les tient de « that » et de « at », et aucun ne figure dans les profils français et espagnol ; sur « ça va », les trois langues sont à la distance maximale et c’est l’ordre alphabétique qui répond ; sur une phrase française suivie d’une phrase anglaise, l’espagnol arrive deuxième alors qu’il n’est nulle part dans le texte.

Quand passer au niveau suivant

Vos textes tiennent en un ou deux mots, un objet de message ou un champ de recherche. C’est leur longueur qui doit vous alerter, pas l’écart : l’écart ne signale rien sur un mot isolé, « chat » ressort en anglais avec un écart plus large que celui d’une phrase française entière.

Les quatre options

  1. RecommandéN0Profils de trigrammes de caractères, distance de rangGratuit · Instantané
  2. N1Classifieur bayésien naïf sur n-grammes de caractèresnégligeable · Instantané
  3. N2Identifiant de langue dédié, embarqué avec son modèle (CLD3)négligeable · Instantané
  4. N3Détection par appel à un modèle généralisteélevé · Une seconde

Les autres options

N1Classifieur bayésien naïf sur n-grammes de caractèresnégligeable · Instantané

Classifieur bayésien naïf sur n-grammes de caractères

Modèle classique légerN1négligeableInstantané

Python

snippets/detect-language-of-text/n1.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Détecter la langue d'un texte avec un classifieur bayésien naïf sur des
n-grammes de caractères.

Niveau N1. Des traits voisins de ceux de N0, des n-grammes d'un à trois
caractères découpés aux frontières des mots, mais pondérés au lieu d'être
classés. Chaque n-gramme du texte vote pour chaque langue, en
proportion de la fréquence à laquelle cette langue l'emploie, et les votes
se multiplient entre eux.

Ce que cela apporte sur la distance de rang de N0, c'est un nombre dont
l'appelant peut faire quelque chose. N0 répond « français » ; celui-ci
répond « français, et voici de combien il devance l'espagnol ». Un détecteur
qui sait s'abstenir vaut mieux qu'un détecteur qui a raison un peu plus
souvent.

L'entraînement demande un paragraphe par langue. Le modèle est une table de
comptes.
"""

from sklearn.feature_extraction.text import CountVectorizer
from sklearn.naive_bayes import MultinomialNB
from sklearn.pipeline import make_pipeline


def train(samples: dict[str, str]):
    """
    Fit on one sample of text per language.

    `char_wb` cuts n-grams inside word boundaries, so an n-gram carries the
    information that it opens or closes a word, exactly as the padding of N0
    did. Smoothing is light: an n-gram this language never used should count
    against it, without ruling it out on a single character.
    """
    model = make_pipeline(
        CountVectorizer(analyzer="char_wb", ngram_range=(1, 3), lowercase=True),
        MultinomialNB(alpha=0.1),
    )
    model.fit(list(samples.values()), list(samples.keys()))
    return model


def probabilities(model, text: str) -> dict[str, float]:
    """
    How the text splits between the known languages.

    Read these as an ordering, not as a measure of truth: they always sum to
    one, over the languages the model was trained on and no others.
    """
    scores = model.predict_proba([text])[0]
    return {str(name): float(score) for name, score in zip(model.classes_, scores)}


def detect(model, text: str, minimum: float = 0.0) -> str | None:
    """
    The most likely language, or None when the model is not sure enough.

    `minimum` is yours to set. Raise it when a wrong language costs more than
    no answer, for instance when the answer picks the queue a message is
    routed to. Leave it at zero to always get a name, as N0 does.
    """
    best, score = max(probabilities(model, text).items(), key=lambda item: item[1])
    return best if score >= minimum else None

JavaScript

snippets/detect-language-of-text/n1.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Détecter la langue d'un texte avec un classifieur bayésien naïf sur des
 * n-grammes de caractères.
 *
 * Niveau N1. Des traits voisins de ceux de N0, des n-grammes d'un à trois
 * caractères découpés aux frontières des mots, mais pondérés au lieu d'être
 * classés. Chaque n-gramme du texte vote pour chaque langue, en
 * proportion de la fréquence à laquelle cette langue l'emploie, et les votes
 * se multiplient entre eux.
 *
 * Ce que cela apporte sur la distance de rang de N0, c'est un nombre dont
 * l'appelant peut faire quelque chose. N0 répond « français » ; celui-ci
 * répond « français, et voici de combien il devance l'espagnol ». Un détecteur
 * qui sait s'abstenir vaut mieux qu'un détecteur qui a raison un peu plus
 * souvent.
 *
 * Écrit en entier plutôt que tiré d'une bibliothèque, parce qu'un bayésien
 * naïf multinomial n'est que du comptage, et tient dans ce fichier. C'est l'argument de ce
 * niveau : l'outil classique est assez petit pour être lu.
 */

// Smoothing. An n-gram a language never used should count against it, without
// ruling the language out on the strength of a single character.
const ALPHA = 0.1;

/**
 * The n-grams of one to three characters of every word, padded with spaces so
 * that an n-gram carries the information that it opens or closes a word.
 */
export function ngrams(text) {
  const grams = [];
  for (const word of text.toLowerCase().split(/\s+/).filter(Boolean)) {
    const padded = ` ${word} `;
    for (let n = 1; n <= 3; n += 1) {
      for (let i = 0; i + n <= padded.length; i += 1) grams.push(padded.slice(i, i + n));
    }
  }
  return grams;
}

/** Fit on one sample of text per language: `{ fr: "…", en: "…" }`. */
export function train(samples) {
  const vocabulary = new Set();
  const counts = new Map();
  for (const [language, sample] of Object.entries(samples)) {
    const perLanguage = new Map();
    for (const gram of ngrams(sample)) {
      perLanguage.set(gram, (perLanguage.get(gram) ?? 0) + 1);
      vocabulary.add(gram);
    }
    counts.set(language, perLanguage);
  }
  return { counts, vocabulary };
}

/**
 * How the text splits between the known languages.
 *
 * Read these as an ordering, not as a measure of truth: they always sum to
 * one, over the languages the model was trained on and no others.
 */
export function probabilities(model, text) {
  const { counts, vocabulary } = model;
  const grams = ngrams(text).filter((gram) => vocabulary.has(gram));
  const logScores = [...counts].map(([language, perLanguage]) => {
    const total = [...perLanguage.values()].reduce((a, b) => a + b, 0);
    const denominator = total + ALPHA * vocabulary.size;
    // Sum of logs rather than a product of probabilities: multiplying a few
    // thousand small numbers underflows to zero.
    let score = 0;
    for (const gram of grams) score += Math.log(((perLanguage.get(gram) ?? 0) + ALPHA) / denominator);
    return [language, score];
  });
  const highest = Math.max(...logScores.map(([, score]) => score));
  const weights = logScores.map(([language, score]) => [language, Math.exp(score - highest)]);
  const sum = weights.reduce((a, [, weight]) => a + weight, 0);
  return Object.fromEntries(weights.map(([language, weight]) => [language, weight / sum]));
}

/**
 * The most likely language, or null when the model is not sure enough.
 *
 * `minimum` is yours to set. Raise it when a wrong language costs more than no
 * answer, for instance when the answer picks the queue a message is routed to.
 * Leave it at zero to always get a name, as N0 does.
 */
export function detect(model, text, minimum = 0) {
  const scores = Object.entries(probabilities(model, text));
  const [best, score] = scores.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))[0];
  return score >= minimum ? best : null;
}

Ce que ça implique

  • Vos données ne sortent pas
  • Résultat toujours identique
  • Se mesure sur un échantillon, pas cas par cas
  • Dépend d’une bibliothèque
  • Empreinte faible

Ce que cela ajoute à votre périmètre réglementaire

  • Aucun périmètre spécifique ajouté : l’échantillon d’entraînement est un texte de langue, pas les messages de vos utilisateurs
  • Le texte identifié reste sur votre infrastructure

Point de rupture

La probabilité est un produit sur tous les n-grammes du texte : elle sature bien avant que les indices ne le justifient. Sur la phrase française suivie d’une phrase anglaise, le modèle n’a plus l’hésitation de N0, il répond français avec une quasi-certitude et le seuil qui s’abstenait sur « ça va » ne se déclenche pas ; et comme les probabilités somment à un sur les seules langues apprises, un texte portugais ressort en espagnol et un texte allemand en anglais, tous deux au-dessus du seuil.

Quand passer au niveau suivant

Vous devez reconnaître une langue dont vous n’avez aucun échantillon, et réentraîner à chaque nouvelle langue n’est pas envisageable : un identifiant dédié, embarqué avec son modèle, couvre une centaine de langues sans échantillon de votre part et sans que le texte quitte votre machine.

N2Identifiant de langue dédié, embarqué avec son modèle (CLD3)négligeable · Instantané

Identifiant de langue dédié, embarqué avec son modèle (CLD3)

Petit modèle spécialisé auto-hébergéN2négligeableInstantané

Python

snippets/detect-language-of-text/n2.pyTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
"""
Détecter la langue avec un identifiant dédié, hébergé chez vous.

Niveau N2. N0 et N1 ne connaissent que les langues dont vous leur avez donné
un échantillon. CLD3, le Compact Language Detector de Google, est un petit
réseau de neurones qui couvre une centaine de langues et que le paquet
embarque avec son modèle : rien à échantillonner, rien à entraîner, pas de
clé, pas de téléchargement, et aucun texte ne quitte votre machine. « gcld3 »
en Python, « cld3-asm » en JavaScript, le même modèle dessous.

Ce que vous possédez à ce niveau n'est pas le modèle. Ce sont trois choses.

La correspondance entre ses codes et les vôtres : CLD3 répond en style
BCP 47, et distingue l'hindi en écriture latine de l'hindi en devanagari par
« hi-Latn ». L'appelant qui attend « hi » reçoit un code qu'il n'a jamais vu.

Le refus, et le plancher d'octets sur lequel il repose. En dessous de
« min_bytes », la bibliothèque répond « und » au lieu de deviner, et ce
plancher — 140 octets par défaut — est justement ce que N0 et N1 n'ont pas :
c'est le message de deux mots, l'objet de courriel, le champ de recherche,
refusés par la bibliothèque elle-même. Au-dessus du plancher, « is_reliable »
et « probability » sont deux poignées de plus, et cet extrait fait des trois
un refus.

Et le chargement. Le modèle est lu quand l'identifiant est construit, et
l'identifiant retient de la mémoire dont le ramasse-miettes ne s'occupe pas :
construisez-en un, gardez-le, n'en construisez pas un par message.
"""

from __future__ import annotations

# The library's own defaults, read from its header: below 140 bytes it returns
# "und", and it predicts on the first 700 bytes of what it is given. Lower the
# floor and you get an answer on a two-word message; you also get the guess
# this whole entry is about.
MIN_BYTES, MAX_BYTES = 140, 700

# Below this probability the answer is not used. CLD3 also carries its own
# `is_reliable`, computed from the text it saw; both have to agree.
THRESHOLD = 0.7


class IdentificationUnavailable(Exception):
    """The identifier could not be loaded, or answered something unusable."""


class Cld3Identifier:
    """The real identifier, and the whole surface of the library this needs."""

    def __init__(self, min_bytes: int = MIN_BYTES, max_bytes: int = MAX_BYTES, module=None) -> None:
        if module is None:
            import gcld3  # a local install, model included, no network and no key

            module = gcld3
        self._identifier = module.NNetLanguageIdentifier(
            min_num_bytes=min_bytes, max_num_bytes=max_bytes
        )

    def find(self, text: str) -> dict:
        """One answer: the code, how sure the model is, and its own verdict on that."""
        found = self._identifier.FindLanguage(text=text)
        return {
            "language": found.language,
            "probability": float(found.probability),
            "is_reliable": bool(found.is_reliable),
        }


_LOADED: Cld3Identifier | None = None


def loaded_identifier() -> Cld3Identifier:
    """The default identifier, built on first use and kept."""
    global _LOADED
    if _LOADED is None:
        _LOADED = Cld3Identifier()
    return _LOADED


def detect(text: str, identifier=None, threshold: float = THRESHOLD) -> str | None:
    """
    Return the language code, or None when nothing can be said.

    None on four counts, and they are not the same thing said four times: an
    empty text, the code CLD3 uses for "undetermined" — which is also what it
    answers on a text below the byte floor —, its own `is_reliable` set to
    false, and a probability below the threshold. Returning a language in any
    of those cases would hand the caller a guess wearing the clothes of a
    fact, which is the failure this whole entry is about.

    `identifier` is injected so this can be tested without installing the
    library. In production it defaults to the real one above.
    """
    if not text.strip():
        return None
    identifier = identifier or loaded_identifier()
    try:
        found = identifier.find(text)
    except Exception as error:  # noqa: BLE001 - a library failure is not the caller's fault
        raise IdentificationUnavailable(str(error)) from error
    if not isinstance(found, dict) or not isinstance(found.get("language"), str):
        raise IdentificationUnavailable("the identifier returned no language")
    if found["language"] == "und" or not found.get("is_reliable"):
        return None
    return found["language"] if float(found.get("probability", 0.0)) >= threshold else None

JavaScript

snippets/detect-language-of-text/n2.jsTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
/**
 * Détecter la langue avec un identifiant dédié, hébergé chez vous.
 *
 * Niveau N2. N0 et N1 ne connaissent que les langues dont vous leur avez donné
 * un échantillon. CLD3, le Compact Language Detector de Google, est un petit
 * réseau de neurones qui couvre une centaine de langues et que le paquet
 * embarque avec son modèle : rien à échantillonner, rien à entraîner, pas de
 * clé, pas de téléchargement, et aucun texte ne quitte votre machine. « gcld3 »
 * en Python, « cld3-asm » en JavaScript, le même modèle dessous.
 *
 * Ce que vous possédez à ce niveau n'est pas le modèle. Ce sont trois choses.
 *
 * La correspondance entre ses codes et les vôtres : CLD3 répond en style
 * BCP 47, et distingue l'hindi en écriture latine de l'hindi en devanagari par
 * « hi-Latn ». L'appelant qui attend « hi » reçoit un code qu'il n'a jamais vu.
 *
 * Le refus, et le plancher d'octets sur lequel il repose. En dessous de
 * « min_bytes », la bibliothèque répond « und » au lieu de deviner, et ce
 * plancher — 140 octets par défaut — est justement ce que N0 et N1 n'ont pas :
 * c'est le message de deux mots, l'objet de courriel, le champ de recherche,
 * refusés par la bibliothèque elle-même. Au-dessus du plancher, « is_reliable »
 * et « probability » sont deux poignées de plus, et cet extrait fait des trois
 * un refus.
 *
 * Et le chargement. Le modèle est lu quand l'identifiant est construit, et
 * l'identifiant retient de la mémoire dont le ramasse-miettes ne s'occupe pas :
 * construisez-en un, gardez-le, n'en construisez pas un par message.
 */

// The library's own defaults, read from its header: below 140 bytes it returns
// "und", and it predicts on the first 700 bytes of what it is given. Lower the
// floor and you get an answer on a two-word message; you also get the guess
// this whole entry is about.
export const MIN_BYTES = 140;
export const MAX_BYTES = 700;

// Below this probability the answer is not used. CLD3 also carries its own
// `is_reliable`, computed from the text it saw; both have to agree.
export const THRESHOLD = 0.7;

export class IdentificationUnavailable extends Error {}

/** The real identifier, and the whole surface of the library this needs. */
export async function cld3Identifier({ minBytes = MIN_BYTES, maxBytes = MAX_BYTES, load } = {}) {
  // A local install, model included, no network and no key.
  const { loadModule } = await (load ? load() : import('cld3-asm'));
  const factory = await loadModule();
  const identifier = factory.create(minBytes, maxBytes);
  return {
    /** One answer: the code, how sure the model is, and its own verdict on that. */
    find(text) {
      const found = identifier.findLanguage(text);
      return {
        language: found.language,
        probability: Number(found.probability),
        is_reliable: Boolean(found.is_reliable),
      };
    },
  };
}

let loaded;

/** The default identifier, built on first use and kept. */
export function loadedIdentifier(options) {
  loaded ??= cld3Identifier(options).catch((error) => {
    loaded = undefined; // a failed load is not a cached answer
    throw error;
  });
  return loaded;
}

/**
 * Return the language code, or null when nothing can be said.
 *
 * Null on four counts, and they are not the same thing said four times: an
 * empty text, the code CLD3 uses for "undetermined" — which is also what it
 * answers on a text below the byte floor —, its own `is_reliable` set to
 * false, and a probability below the threshold. Returning a language in any of
 * those cases would hand the caller a guess wearing the clothes of a fact,
 * which is the failure this whole entry is about.
 *
 * `identifier` is injected so this can be tested without installing the
 * library. In production it defaults to the real one above.
 */
export async function detect(text, { identifier, threshold = THRESHOLD } = {}) {
  if (!text.trim()) return null;
  const engine = identifier ?? (await loadedIdentifier());
  let found;
  try {
    found = await engine.find(text);
  } catch (error) {
    throw new IdentificationUnavailable(String(error));
  }
  if (!found || typeof found.language !== 'string') {
    throw new IdentificationUnavailable('the identifier returned no language');
  }
  if (found.language === 'und' || !found.is_reliable) return null;
  return Number(found.probability) >= threshold ? found.language : null;
}

Ce que ça implique

  • Vos données ne sortent pas
  • Résultat toujours identique
  • Se mesure sur un échantillon, pas cas par cas
  • Dépend d’une bibliothèque
  • Empreinte faible

Ce que cela ajoute à votre périmètre réglementaire

  • Aucun périmètre spécifique ajouté : le texte ne quitte pas votre infrastructure, le modèle est dans le paquet et rien n’est téléchargé à l’exécution
  • Les poids viennent d’un tiers, sous licence Apache 2.0, et le dépôt est archivé depuis le 15 juin 2024 : le modèle est figé, personne ne corrigera une langue mal reconnue

Point de rupture

Le code rendu est celui de CLD3, pas le vôtre : il répond en style BCP 47 et distingue l’hindi en écriture latine de l’hindi en devanagari par « hi-Latn », un code que l’appelant qui attend « hi » n’a jamais vu, sur la même langue. Et le plancher qui fait la valeur de ce niveau en fait aussi la limite : sa documentation donne 140 octets en dessous desquels il répond « und » — sur un objet de message, il ne se trompe pas, il ne répond pas.

Quand passer au niveau suivant

Il vous faut une réponse malgré tout sur un texte plus court que ce plancher, ou sur une langue absente de la centaine que couvre le modèle — en sachant que le niveau au-dessus coûte un appel facturé et ne sait pas davantage dire qu’il se trompe.

N3Détection par appel à un modèle généralisteélevé · Une seconde

Détection par appel à un modèle généraliste

API de LLM généralisteN3élevéUne seconde

Python

snippets/detect-language-of-text/n3.pyTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
"""
Détecter la langue d'un texte en interrogeant un modèle généraliste.

Niveau N3. C'est l'option vers laquelle on se tourne d'abord. Elle est ici
pour qu'on voie ce qu'elle coûte, pas parce que cette fiche la recommande.

Voyez ce que le code doit faire et que N0 ne faisait pas : n'envoyer qu'un
extrait, réessayer en cas d'échec, analyser une réponse qui n'est que
probablement du JSON valide, normaliser un code que le modèle peut écrire en
capitales, avec une région ou entouré d'espaces, et refuser une réponse qui
sort de la liste qu'on lui a fournie. Cette plomberie est le vrai coût de ce
niveau, et c'est la part que vos tests doivent couvrir, parce que le modèle
lui-même n'est pas testable.

La seule chose que ce niveau apporte vraiment, c'est qu'il n'a besoin
d'aucun échantillon de la langue. La seule chose qu'il ne sait pas faire,
c'est vous dire qu'il se trompe.
"""

from __future__ import annotations

import json
from collections.abc import Collection

# 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.
MODEL = "gpt-4.1-mini"  # an example id: check the parameters your model accepts

PROMPT = (
    "Identify the language of the text below.\n"
    "Answer with JSON only: an object with the key `language`, whose value is\n"
    "a two-letter ISO 639-1 code chosen from this list:\n"
    "{languages}, or `und` if the text is in none of them.\n\n"
    "Text:\n{excerpt}"
)

# Only the first characters are sent, a few sentences: N0 and N1 already name
# the language of a single sentence, and the model bills every token past it.
# That excerpt is the whole cost control — there is no cap on the input, because
# a cap would refuse a long email that costs exactly the same as a short one.
EXCERPT_CHARACTERS = 600


class DetectionUnavailable(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 detect(text: str, languages: Collection[str], client=None, *, attempts: int = 3) -> str | None:
    """
    Return the code of the detected language, or None when the text is blank
    or the model says it is in none of the languages it was offered.

    `client` is injected so this function can be tested without a network
    call. In production it defaults to a real provider client.
    """
    # A blank text costs nothing to refuse and cannot say anything. A long one
    # is not refused: only its first characters are ever sent.
    if not text.strip():
        return None

    client = client or ProviderClient()
    answer = _ask(client, text[:EXCERPT_CHARACTERS], sorted(languages), attempts)

    # "fr", "FR", "fr-CA" and the locale form "fr_CA" are all read as "fr". A
    # language name such as "French" is not a code, and is refused below.
    code = str(answer.get("language", "")).strip().lower().replace("_", "-").split("-")[0]
    if code == "und":
        return None
    if code not in languages:
        raise DetectionUnavailable(f"the model answered a language outside the list: {code!r}")
    return code


def _ask(client, excerpt: str, languages: list[str], attempts: int) -> dict:
    last_error: Exception | None = None
    for _ in range(attempts):
        try:
            answer = client.complete(
                prompt=PROMPT.format(languages=", ".join(languages), excerpt=excerpt),
                # Temperature zero, because a routing decision that changes
                # between two identical calls cannot be reviewed.
                temperature=0,
            )
            # No content at all (a refusal) is as unusable as prose.
            parsed = json.loads(answer) if isinstance(answer, str) else None
            if isinstance(parsed, dict):
                return parsed
            last_error = ValueError("the model answered something that is not an object")
        except (TypeError, NameError, AttributeError):
            # A programming error in this file is not a provider failure:
            # retrying it would pay three bills for one bug.
            raise
        except Exception as error:  # noqa: BLE001 - any provider failure is retried
            last_error = error
    raise DetectionUnavailable(str(last_error))

JavaScript

snippets/detect-language-of-text/n3.jsTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
/**
 * Détecter la langue d'un texte en interrogeant un modèle généraliste.
 *
 * Niveau N3. C'est l'option vers laquelle on se tourne d'abord. Elle est ici
 * pour qu'on voie ce qu'elle coûte, pas parce que cette fiche la recommande.
 *
 * Voyez ce que le code doit faire et que N0 ne faisait pas : n'envoyer qu'un
 * extrait, réessayer en cas d'échec, analyser une réponse qui n'est que
 * probablement du JSON valide, normaliser un code que le modèle peut écrire en
 * capitales, avec une région ou entouré d'espaces, et refuser une réponse qui
 * sort de la liste qu'on lui a fournie. Cette plomberie est le vrai coût de ce
 * niveau, et c'est la part que vos tests doivent couvrir, parce que le modèle
 * lui-même n'est pas testable.
 *
 * La seule chose que ce niveau apporte vraiment, c'est qu'il n'a besoin
 * d'aucun échantillon de la langue. La seule chose qu'il ne sait pas faire,
 * c'est vous dire qu'il se trompe.
 */

// 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.
export const MODEL = 'gpt-4.1-mini'; // an example id: check the parameters your model accepts

// Only the first characters are sent, a few sentences: N0 and N1 already name
// the language of a single sentence, and the model bills every token past it.
// That excerpt is the whole cost control — there is no cap on the input,
// because a cap would refuse a long email that costs exactly the same as a
// short one.
export const EXCERPT_CHARACTERS = 600;

export class DetectionUnavailable extends Error {}

export async function providerClient(sdk, model = MODEL) {
  if (!sdk) {
    const { OpenAI } = await import('openai');
    sdk = new OpenAI();
  }
  return {
    async complete({ prompt, temperature }) {
      const response = await sdk.chat.completions.create({
        model,
        messages: [{ role: 'user', content: prompt }],
        temperature,
      });
      return response.choices[0].message.content;
    },
  };
}

/** The exact request sent to the model. Exported so a test can read it. */
export function buildPrompt(languages, excerpt) {
  return [
    'Identify the language of the text below.',
    'Answer with JSON only: an object with the key `language`, whose value is',
    'a two-letter ISO 639-1 code chosen from this list:',
    `${[...languages].sort().join(', ')}, or \`und\` if the text is in none of them.`,
    '',
    'Text:',
    excerpt,
  ].join('\n');
}

/**
 * The code of the detected language, or null when the text is blank or the
 * model says it is in none of the languages it was offered.
 *
 * @param {string} text
 * @param {string[]} languages the codes the model may choose from
 * @param {object} options
 * @param {{complete: Function}} [options.client] injected so this can be
 *   tested without a network call; defaults to a real provider client
 * @param {number} [options.attempts]
 */
export async function detect(text, languages, { client, attempts = 3 } = {}) {
  // Counted in characters, as Python counts them: `length` would count an
  // emoji twice, and `slice` could cut one in half.
  const characters = [...text];

  // A blank text costs nothing to refuse and cannot say anything. A long one is
  // not refused: only its first characters are ever sent.
  if (!text.trim()) return null;

  client ??= await providerClient();
  const excerpt = characters.slice(0, EXCERPT_CHARACTERS).join('');
  const answer = await ask(client, excerpt, languages, attempts);

  // "fr", "FR", "fr-CA" and the locale form "fr_CA" are all read as "fr". A
  // language name such as "French" is not a code, and is refused below.
  const code = String(answer.language ?? '').trim().toLowerCase().replace('_', '-').split('-')[0];
  if (code === 'und') return null;
  if (![...languages].includes(code)) {
    throw new DetectionUnavailable(`the model answered a language outside the list: ${code}`);
  }
  return code;
}

async function ask(client, excerpt, languages, attempts) {
  let lastError;
  for (let i = 0; i < attempts; i += 1) {
    try {
      const answer = await client.complete({
        prompt: buildPrompt(languages, excerpt),
        // Temperature zero, because a routing decision that changes between
        // two identical calls cannot be reviewed.
        temperature: 0,
      });
      // No content at all (a refusal) is as unusable as prose.
      const parsed = typeof answer === 'string' ? JSON.parse(answer) : null;
      if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
      lastError = new Error('the model answered something that is not an object');
    } catch (error) {
      // Only what the provider or the decoder can raise. A TypeError from this
      // file is a bug, and retrying it would pay three bills for it.
      if (error instanceof TypeError || error instanceof ReferenceError) throw error;
      lastError = error;
    }
  }
  throw new DetectionUnavailable(String(lastError));
}

Ce que ça implique

  • Vos données partent chez un tiers
  • Résultat variable d’un appel à l’autre
  • Difficile à tester
  • Dépend d’un fournisseur externe
  • Empreinte élevée

Ce que cela ajoute à votre périmètre réglementaire

  • Transfert du texte à un sous-traitant, avec l’encadrement contractuel que cela suppose
  • Le texte envoyé est un message d’utilisateur : il peut porter des données personnelles que rien dans l’appel ne retire
  • Localisation du traitement à vérifier auprès du fournisseur
  • Ne vous dispense pas de vos propres obligations d’information et de minimisation

Point de rupture

Une confiance écrite par le modèle n’est pas mesurée, et l’extrait n’en lit aucune. Le test lui fait répondre « es » sur une phrase manifestement française, assorti d’une confiance de 0,99 puis de 0,01 : le code est dans la liste, l’extrait le rend les deux fois. Il ne lève une erreur que sur ce qu’il peut voir, de la prose là où du JSON était demandé, ou une langue absente de la liste qu’il avait fournie.

Quand passer au niveau suivant

Il n’y a pas de niveau au-dessus.

Pour aller plus loin

Source des chiffres

Métadonnées