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

Le verdict

N3, sans détour : ce qu’on achète ici est la variété, et c’est précisément ce que le niveau du bas ne fabrique pas — seize formulations écrites dans l’extrait, qui retombent en douze motifs de phrase sur deux cents produits une fois les valeurs propres à l’article effacées, les deux mesurés par le test de N0 ; et la dix-septième formulation s’écrit à la main par quelqu’un qui en a déjà écrit seize. N2 réclame d’avoir déjà publié le catalogue qu’on lui demande d’écrire, et le challenge E2E a trouvé que les modèles de séquence à séquence sans contrôle sémantique rendent souvent mal les attributs qu’on leur donne : il réclame le même contrôle que N3. Le prix est réel, un texte facturé au jeton par article, vos dossiers produit chez un tiers, et une relecture à refaire à chaque régénération, puisque deux appels sur le même dossier n’écrivent pas la même phrase ; la copie, elle, se génère une fois, se range dans le catalogue et s’y versionne comme un texte de rédacteur — c’est le texte stocké qui est publié, pas un appel par affichage de page. Ce qu’il achète, c’est une boutique qui ne se lit pas comme un formulaire. Pour deux cents articles générés d’un coup, l’API de traitement par lots du fournisseur d’exemple est faite pour ça et coûte moins cher que deux cents appels un par un. Si votre catalogue tient sur trente références qu’un rédacteur écrit une fois pour toutes, aucun niveau de cette fiche n’est la réponse.

Rédaction par appel à un modèle généraliste, avec contrôle d’ancrage sur le dossier produit

Oui, un modèle généraliste se justifie

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

Python

snippets/write-product-descriptions/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.
"""
Rédiger une description produit en interrogeant un modèle généraliste.

Niveau N3. Sur la plupart des fiches de ce site, ce niveau est la réponse
chère à une question qui ne la demandait pas. Ici, c'est lui qui gagne : le
gabarit du niveau N0 tire chaque phrase d'une liste écrite d'avance, et son
test compte les motifs qui se répètent, quand ce niveau demande une prose
nouvelle à chaque appel.

Ce qu'il coûte est visible dans le code, et rien de tout cela n'est le fait
du modèle : la requête, le plafond de taille, la reprise, une réponse qui
n'est que probablement du JSON, et une température supérieure à zéro — c'est
la variété qu'on achète ici, et le kit documente une température plus haute
comme rendant la sortie plus aléatoire, si bien que rien ne peut être relu
une fois et tenu pour bon ensuite.

C'est pourquoi le dernier contrôle est celui du niveau du dessous. Un modèle
qui écrit librement affirme aussi librement. La copie est relue contre le
dossier, terme à terme, et tout ce que le dossier n'étaye pas est refusé
plutôt que publié.
"""

from __future__ import annotations

import json
import re
import unicodedata

# The instructions are written in the language of the shop, like the copy.
PROMPT = (
    "Tu rédiges la présentation d'un article pour une boutique en ligne.\n"
    "Écris deux phrases en français, sans superlatif, et n'affirme rien qui ne\n"
    "figure pas dans les caractéristiques ci-dessous.\n"
    "Réponds par un objet JSON et rien d'autre : {\"description\": \"…\"}.\n"
    "\n"
    "Caractéristiques :"
)

# A model charges by the token. Refusing an oversized record is not an
# optimisation, it is a cost control.
MAX_CHARACTERS = 600

# Shorter than that, the model answered a fragment and not a description.
MIN_CHARACTERS = 40


class DescriptionUnavailable(Exception):
    """The provider failed, or answered something no shop can publish."""


class UngroundedDescription(DescriptionUnavailable):
    """The copy claims an attribute the product record does not carry."""


# 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


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 describe(
    product: dict,
    client=None,
    *,
    vocabulary=(),
    attempts: int = 3,
    temperature: float = 0.7,
) -> str:
    """
    Write the description of one product.

    `client` is injected so this can be tested without a network call; in
    production it defaults to a real provider client.

    `vocabulary` is the attribute words your catalogue uses — materials,
    finishes, features, claims. A word of that list found in the copy and
    nowhere in the record is an invention, and refused. An empty vocabulary
    switches the check off, which is a decision, not a default to leave alone.
    """
    attributes = _attributes(product)
    if not attributes:
        raise ValueError("empty product record: nothing to describe")
    if len(attributes) > MAX_CHARACTERS:
        raise ValueError(f"product record longer than {MAX_CHARACTERS} characters")
    client = client or ProviderClient()

    description = _ask(client, f"{PROMPT}\n{attributes}", attempts, temperature)
    if len(description) < MIN_CHARACTERS:
        raise DescriptionUnavailable("the model answered a fragment")

    invented = [
        term for term in vocabulary if _states(description, term) and not _states(attributes, term)
    ]
    if invented:
        raise UngroundedDescription(", ".join(invented))
    return description


def _attributes(product: dict) -> str:
    """One « field: value » line per attribute, which is what the model reads."""
    lines = []
    for key, value in product.items():
        items = value if isinstance(value, (list, tuple)) else [value]
        joined = ", ".join(str(item) for item in items if item is not None)  # None: a NULL column
        if joined.strip():
            lines.append(f"- {key} : {joined.strip()}")
    return "\n".join(lines)


def _ask(client, prompt: str, attempts: int, temperature: float) -> str:
    """Call the provider, decode the answer, and retry what can be retried."""
    last_error: Exception | None = None
    for _ in range(attempts):
        try:
            answer = _decode(client.complete(prompt=prompt, temperature=temperature))
            written = answer.get("description") if isinstance(answer, dict) else None
            if isinstance(written, str) and written.strip():
                return " ".join(written.split())
            last_error = ValueError("the model answered without a description")
        except Exception as error:  # noqa: BLE001 - any provider failure is retried
            last_error = error
    raise DescriptionUnavailable(str(last_error))


def _decode(answer):
    """JSON, or JSON wrapped whole in one code fence. No text (a refusal) is unusable."""
    if not isinstance(answer, str):
        raise ValueError("the model returned no text")
    text = answer.strip()
    if text.startswith("```") and text.endswith("```") and text.count("```") == 2:
        text = text[3:-3].removeprefix("json")
    return json.loads(text)


# A term right after one of these words is denied, not stated: « non étanche ».
NEGATIONS = frozenset({"non", "pas", "sans", "ni", "aucun", "aucune"})


def _states(text: str, term: str) -> bool:
    """
    Whether the text states the term: whole words, case and accents set aside,
    each word allowed the agreement endings e, s and es (« garantie à vie »
    states « garanti à vie »), and not right after a negation.
    """
    folded = _fold(text)
    words = r"\s+".join(re.escape(word) + "(?:e|s|es)?" for word in _fold(term).split())
    for found in re.finditer(rf"(?<!\w){words}(?!\w)", folded):
        before = re.findall(r"\w+", folded[: found.start()])
        if not before or before[-1] not in NEGATIONS:
            return True
    return False


def _fold(text: str) -> str:
    """Lowercase and drop accents, so « À vie » meets « a vie »."""
    letters = unicodedata.normalize("NFD", str(text).lower())
    return "".join(char for char in letters if not unicodedata.combining(char))

JavaScript

snippets/write-product-descriptions/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.
/**
 * Rédiger une description produit en interrogeant un modèle généraliste.
 *
 * Niveau N3. Sur la plupart des fiches de ce site, ce niveau est la réponse
 * chère à une question qui ne la demandait pas. Ici, c'est lui qui gagne : le
 * gabarit du niveau N0 tire chaque phrase d'une liste écrite d'avance, et son
 * test compte les motifs qui se répètent, quand ce niveau demande une prose
 * nouvelle à chaque appel.
 *
 * Ce qu'il coûte est visible dans le code, et rien de tout cela n'est le fait
 * du modèle : la requête, le plafond de taille, la reprise, une réponse qui
 * n'est que probablement du JSON, et une température supérieure à zéro — c'est
 * la variété qu'on achète ici, et le kit documente une température plus haute
 * comme rendant la sortie plus aléatoire, si bien que rien ne peut être relu
 * une fois et tenu pour bon ensuite.
 *
 * C'est pourquoi le dernier contrôle est celui du niveau du dessous. Un modèle
 * qui écrit librement affirme aussi librement. La copie est relue contre le
 * dossier, terme à terme, et tout ce que le dossier n'étaye pas est refusé
 * plutôt que publié.
 */

// The instructions are written in the language of the shop, like the copy.
const PROMPT = [
  "Tu rédiges la présentation d'un article pour une boutique en ligne.",
  'Écris deux phrases en français, sans superlatif, et n\'affirme rien qui ne',
  'figure pas dans les caractéristiques ci-dessous.',
  'Réponds par un objet JSON et rien d\'autre : {"description": "…"}.',
  '',
  'Caractéristiques :',
].join('\n');

// A model charges by the token. Refusing an oversized record is not an
// optimisation, it is a cost control.
export const MAX_CHARACTERS = 600;

// Shorter than that, the model answered a fragment and not a description.
export const MIN_CHARACTERS = 40;

export class DescriptionUnavailable extends Error {}

export class UngroundedDescription extends DescriptionUnavailable {}

// 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

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;
    },
  };
}

/**
 * Write the description of one product.
 *
 * `client` is injected so this can be tested without a network call; in
 * production it defaults to a real provider client.
 *
 * `vocabulary` is the attribute words your catalogue uses — materials,
 * finishes, features, claims. A word of that list found in the copy and
 * nowhere in the record is an invention, and refused. An empty vocabulary
 * switches the check off, which is a decision, not a default to leave alone.
 *
 * @param {Record<string, string|string[]>} product
 * @param {{complete: Function}} [client]
 * @param {{vocabulary?: string[], attempts?: number, temperature?: number}} [options]
 */
export async function describe(
  product,
  client,
  { vocabulary = [], attempts = 3, temperature = 0.7 } = {},
) {
  const attributes = attributeLines(product);
  if (attributes === '') throw new RangeError('empty product record: nothing to describe');
  // Characters are counted as code points, as in Python: an emoji is one.
  if ([...attributes].length > MAX_CHARACTERS) {
    throw new RangeError(`product record longer than ${MAX_CHARACTERS} characters`);
  }
  client ??= await providerClient();

  const description = await ask(client, `${PROMPT}\n${attributes}`, attempts, temperature);
  if (description.length < MIN_CHARACTERS) {
    throw new DescriptionUnavailable('the model answered a fragment');
  }

  const invented = vocabulary.filter((term) => states(description, term) && !states(attributes, term));
  if (invented.length > 0) throw new UngroundedDescription(invented.join(', '));
  return description;
}

/** One « field: value » line per attribute, which is what the model reads. */
function attributeLines(product) {
  const lines = [];
  for (const [key, value] of Object.entries(product)) {
    const items = (Array.isArray(value) ? value : [value]).filter((item) => item != null); // null: a NULL column
    const joined = items.map(String).join(', ');
    if (joined.trim()) lines.push(`- ${key} : ${joined.trim()}`);
  }
  return lines.join('\n');
}

/** Call the provider, decode the answer, and retry what can be retried. */
async function ask(client, prompt, attempts, temperature) {
  let lastError;
  for (let i = 0; i < attempts; i += 1) {
    try {
      const answer = decode(await client.complete({ prompt, temperature }));
      const written = answer && typeof answer === 'object' ? answer.description : null;
      if (typeof written === 'string' && written.trim()) return written.split(/\s+/).filter(Boolean).join(' ');
      lastError = new Error('the model answered without a description');
    } catch (error) {
      lastError = error;
    }
  }
  throw new DescriptionUnavailable(String(lastError));
}

/** JSON, or JSON wrapped whole in one code fence. No text (a refusal) is unusable. */
function decode(answer) {
  if (typeof answer !== 'string') throw new Error('the model returned no text');
  let text = answer.trim();
  if (text.startsWith('```') && text.endsWith('```') && text.split('```').length === 3) {
    text = text.slice(3, -3).replace(/^json/, '');
  }
  return JSON.parse(text);
}

// A term right after one of these words is denied, not stated: « non étanche ».
const NEGATIONS = new Set(['non', 'pas', 'sans', 'ni', 'aucun', 'aucune']);

/**
 * Whether the text states the term: whole words, case and accents set aside,
 * each word allowed the agreement endings e, s and es (« garantie à vie »
 * states « garanti à vie »), and not right after a negation.
 */
function states(text, term) {
  const folded = fold(text);
  const words = fold(term).split(/\s+/).filter(Boolean)
    .map((word) => `${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:e|s|es)?`);
  const pattern = new RegExp(`(?<![\\p{L}\\p{N}_])${words.join('\\s+')}(?![\\p{L}\\p{N}_])`, 'gu');
  for (const found of folded.matchAll(pattern)) {
    const before = folded.slice(0, found.index).match(/[\p{L}\p{N}_]+/gu) ?? [];
    if (!NEGATIONS.has(before.at(-1))) return true;
  }
  return false;
}

/** Lowercase and drop accents, so « À vie » meets « a vie ». */
function fold(text) {
  return String(text)
    .toLowerCase()
    .normalize('NFD')
    .replace(/\p{Diacritic}/gu, '');
}

Essayer

Ce code ne peut pas tourner dans un navigateur. Les sorties ci-dessous sont calculées à la construction du site, en exécutant le vrai code.

Le texte rendu par le modèle est simulé par le double local qui sert aux tests. Ce qui est calculé ici, c’est ce que le code envoie, ce qu’il refuse avant de dépenser, ce qu’il réessaie, et ce que son contrôle contre le dossier attrape. La température envoyée n’est pas nulle, et le kit du fournisseur la décrit comme rendant la sortie plus aléatoire.

  1. Un texte qui s’en tient au dossier

    Donné

    nom : Aurore 500 catégorie : sac à dos matière : toile recyclée public : les randonneurs à la journée équipements : poche pour ordinateur, sangle ventrale coloris : ardoise, sable garantie : deux ans

    Ce qu’il renvoie

    Aurore 500 tient la journée de marche sans se rappeler à vous. Sa toile recyclée encaisse les ronces, et sa poche pour ordinateur rentre au bureau le lundi.

    1 appel facturé, 490 caractères partis chez le fournisseur, consignes et fiche comprises.

  2. La même fiche, avec les notes de réserve restées dedans

    Donné

    la même fiche, notes de l’importateur comprises : 702 caractères d’attributs, 102 de trop

    Ce qu’il renvoie

    Refusé avant le premier appel

    product record longer than 600 characters

    Rien n’est parti chez le fournisseur, rien n’est dû.

  3. Le fournisseur échoue deux fois

    Donné

    la même fiche, mais les deux premiers appels échouent

    Ce qu’il renvoie

    Aurore 500 tient la journée de marche sans se rappeler à vous. Sa toile recyclée encaisse les ronces, et sa poche pour ordinateur rentre au bureau le lundi.

    3 appels facturés : les deux premiers ont échoué, le troisième a répondu.

  4. Le modèle répond en prose

    Donné

    la même fiche ; le modèle répond « Bien sûr ! Voici une proposition… »

    Ce qu’il renvoie

    Réponse rejetée

    Le modèle a répondu en prose là où le code demandait du JSON. Le code lève une erreur plutôt que de publier cela sur une page produit.

    3 appels envoyés et facturés, aucune réponse publiable.

  5. Le modèle promet une garantie à vie

    Donné

    la même fiche, qui dit deux ans de garantie ; le modèle écrit « garanti à vie »

    Ce qu’il renvoie

    Publication refusée : une promesse absente du dossier

    Le contrôle terme à terme a trouvé « garanti à vie » dans le texte et nulle part dans le dossier.

    1 appel facturé, 490 caractères partis chez le fournisseur, consignes et fiche comprises.

  6. Le modèle promet la même chose, avec d’autres mots

    Donné

    la même fiche, qui ne parle nulle part d’étanchéité ; le modèle écrit « ne craint pas la pluie battante »

    Ce qu’il renvoie

    Aurore 500 tient la journée de marche sans se rappeler à vous. Sa toile recyclée ne craint ni les ronces ni la pluie battante, et votre dos reste sec sous l’averse.

    1 appel facturé, 490 caractères partis chez le fournisseur, consignes et fiche comprises.

    Là, ça casseLe texte promet un sac qui garde le dos sec sous l’averse. Le dossier ne dit rien de tel, et la boutique n’a jamais vendu ce sac comme étanche. Le contrôle ne voit rien : il cherche les termes du catalogue, et « étanche » n’est pas écrit — la promesse est dite autrement. Ce contrôle ne vaut que ce que vaut la liste. Le texte part en ligne.

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 de vos dossiers produit à un sous-traitant, nouveautés non encore publiées comprises, avec l’encadrement contractuel que cela suppose
  • Localisation du traitement à vérifier auprès du fournisseur
  • Ne vous dispense pas de répondre des allégations que vous publiez : une phrase écrite par le modèle vous engage comme une phrase écrite par vous

Point de rupture

Le modèle promet ce que la boutique ne vend pas, avec l’aplomb de la phrase vraie qui le précède. Le test fait écrire au double local que le sac est « garanti à vie » quand le dossier dit deux ans. Ce qui l’attrape est le contrôle du niveau du dessous, terme à terme contre le dossier, et il ne vaut que ce que vaut votre liste. S’y ajoute ce qu’on achète ici : l’extrait demande une température non nulle, que le kit du fournisseur décrit comme rendant la sortie plus aléatoire. La copie, elle, se génère une fois par article et se range dans le catalogue : c’est ce texte-là qu’on relit et qu’on publie. Ce qui se paie, c’est la régénération — un dossier produit corrigé, et la phrase relue hier n’est plus celle qui partira.

Quand passer au niveau suivant

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

Les quatre options

  1. N0Gabarits à trous, avec accord grammatical et repli sur les attributs présentsGratuit · Instantané
  2. N1Niveau sans objet ici
  3. N2Petit modèle génératif auto-hébergé (BARThez affiné sur vos descriptions déjà publiées)modéré · Une seconde
  4. RecommandéN3Rédaction par appel à un modèle généraliste, avec contrôle d’ancrage sur le dossier produitélevé · Une seconde

Les autres options

N0Gabarits à trous, avec accord grammatical et repli sur les attributs présentsGratuit · Instantané

Gabarits à trous, avec accord grammatical et repli sur les attributs présents

Règle et algorithme classiqueN0GratuitInstantané

Python

snippets/write-product-descriptions/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Rédiger une description produit en remplissant des gabarits à trous.

Niveau N0. Déterministe, sans dépendance. Un gabarit n'est pas le parent
pauvre du modèle : il n'affirme jamais une caractéristique que le produit n'a
pas, il s'affiche dans le temps qu'il faut pour lire un dictionnaire, et
chaque phrase qu'il peut produire a été écrite et approuvée par un humain
avant la mise en ligne.

Trois choses séparent un gabarit qui survit à un vrai catalogue de celui que
tout le monde écrit en dix minutes et jette au bout d'une semaine.

D'abord, un attribut manquant ne doit pas laisser un trou dans une phrase.
Chaque bloc de la description propose plusieurs formulations ; seules celles
dont tous les trous sont remplis sont éligibles, et celles qui emploient le
plus d'attributs sont préférées. Un produit sans liste de couleurs n'a
simplement pas de phrase de couleur, au lieu de « Disponible en . ».

Ensuite, l'accord n'est pas facultatif sur une page de vente. Les marques
`{un}`, `{e}` et `{s}` portent le genre grammatical du nom de catégorie et le
nombre de la liste énumérée, si bien que « Garantie deux ans » et « Points
forts » sortent justes sans un second gabarit par cas.

Enfin, une énumération s'écrit avec des virgules et une seule conjonction à
la fin, jamais déversée en liste séparée par des virgules.

Ce que ceci ne sait pas faire est le sujet du test de point de rupture placé
à côté.
"""

from __future__ import annotations

import re
import unicodedata

# A slot in a wording: an attribute name, or one of the grammar markers.
SLOT = re.compile(r"\{(\w+)\}")

# The description is built block by block, in this order. Inside a block, the
# wordings say the same thing with different attributes and different words:
# the ones that can be filled are kept, the most informative of those win, and
# the product draws one of them.
BLOCKS = (
    (  # What the product is.
        "{name} : {un} {category} en {material}, pensé{e} pour {audience}.",
        "{name}, {un} {category} en {material} pour {audience}.",
        "{name} : {un} {category} en {material}.",
        "{name}, {un} {category} en {material}.",
        "{name} : {un} {category} pour {audience}.",
        "{name} : {un} {category}.",
        "{name}, {un} {category}.",
    ),
    (  # What it brings.
        "Point{s} fort{s} : {features}.",
        "Au programme : {features}.",
        "Côté équipement : {features}.",
    ),
    (  # What there is to choose.
        "Disponible en {colours}.",
        "À choisir en {colours}.",
        "Existe en {colours}.",
    ),
    (  # What you are promised.
        "Garanti{e} {warranty}.",
        "La garantie court sur {warranty}.",
        "Livré{e} avec {warranty} de garantie.",
    ),
)

# The three markers above that carry grammar rather than an attribute value.
GRAMMAR = ("un", "e", "s")


def describe(product: dict, *, conjunction: str = "et") -> str:
    """
    Render the description of one product.

    `product` maps an attribute name to a string or to a list of strings. Only
    the attributes actually present are used. `gender` holds the grammatical
    gender of the category noun and defaults to masculine, which is the only
    piece of grammar a product database never stores and a French sentence
    always needs.
    """
    values, plural = _slots(product, conjunction)
    feminine = str(product.get("gender", "m")).lower().startswith("f")
    sentences = []
    for wordings in BLOCKS:
        usable = _usable(wordings, values)
        if usable:
            drawn = usable[_variant(str(product.get("name") or ""), len(usable))]
            sentences.append(_fill(drawn, values, plural, feminine))
    return " ".join(sentences)


def _usable(wordings: tuple, values: dict) -> list:
    """
    The wordings that can be filled, and among those the most informative.

    A wording is dropped as soon as one of its slots has no value. Of those
    that remain, only the ones using the most attributes are kept: an attribute
    the shop took the trouble to fill in must not be left out because the draw
    fell on a shorter sentence. Several wordings usually tie, and that tie is
    where the variety of this rung lives.
    """
    scored = []
    for wording in wordings:
        slots = [slot for slot in SLOT.findall(wording) if slot not in GRAMMAR]
        if all(slot in values for slot in slots):
            scored.append((len(slots), wording))
    best = max((count for count, _ in scored), default=0)
    return [wording for count, wording in scored if count == best]


def _slots(product: dict, conjunction: str) -> tuple[dict, set]:
    """
    Attribute values as insertable text, and the names that are plural.

    A value of None, a NULL column, is an attribute the product does not have:
    it must not reach a sentence as the word « None ».
    """
    values: dict[str, str] = {}
    plural: set[str] = set()
    for key, value in product.items():
        if value is None:
            continue
        if isinstance(value, (list, tuple)):
            items = [str(item).strip() for item in value if item is not None and str(item).strip()]
            if len(items) > 1:
                plural.add(key)
            value = _enumerate(items, conjunction)
        if str(value).strip():
            values[key] = str(value).strip()
    return values, plural


def _enumerate(items: list[str], conjunction: str) -> str:
    """« ardoise, sable et bronze » : commas, then the conjunction once."""
    if len(items) < 2:
        return items[0] if items else ""
    return f"{', '.join(items[:-1])} {conjunction} {items[-1]}"


def _fill(wording: str, values: dict, plural: set, feminine: bool) -> str:
    """Write the attributes and the grammar markers into one wording."""
    slots = SLOT.findall(wording)
    # The grammar markers are written last, so an attribute called `s` or `e`
    # cannot quietly take their place.
    filled = {
        **values,
        "un": "une" if feminine else "un",
        "e": "e" if feminine else "",
        "s": "s" if any(slot in plural for slot in slots) else "",
    }
    return SLOT.sub(lambda match: filled[match.group(1)], wording)


def _variant(seed: str, count: int) -> int:
    """
    Draw one wording out of `count`, the same one for the same product.

    Summing the code points is deliberately crude. It has one job: give the
    same answer as the JavaScript version of this snippet, so a catalogue
    rendered by either reads identically. The name is composed first (NFC), so
    an accent typed as two code points draws the same wording as one.
    """
    return sum(ord(char) for char in unicodedata.normalize("NFC", seed)) % count

JavaScript

snippets/write-product-descriptions/n0.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Rédiger une description produit en remplissant des gabarits à trous.
 *
 * Niveau N0. Déterministe, sans dépendance. Un gabarit n'est pas le parent
 * pauvre du modèle : il n'affirme jamais une caractéristique que le produit n'a
 * pas, il s'affiche dans le temps qu'il faut pour lire un dictionnaire, et
 * chaque phrase qu'il peut produire a été écrite et approuvée par un humain
 * avant la mise en ligne.
 *
 * Trois choses séparent un gabarit qui survit à un vrai catalogue de celui que
 * tout le monde écrit en dix minutes et jette au bout d'une semaine.
 *
 * D'abord, un attribut manquant ne doit pas laisser un trou dans une phrase.
 * Chaque bloc de la description propose plusieurs formulations ; seules celles
 * dont tous les trous sont remplis sont éligibles, et celles qui emploient le
 * plus d'attributs sont préférées. Un produit sans liste de couleurs n'a
 * simplement pas de phrase de couleur, au lieu de « Disponible en . ».
 *
 * Ensuite, l'accord n'est pas facultatif sur une page de vente. Les marques
 * `{un}`, `{e}` et `{s}` portent le genre grammatical du nom de catégorie et le
 * nombre de la liste énumérée, si bien que « Garantie deux ans » et « Points
 * forts » sortent justes sans un second gabarit par cas.
 *
 * Enfin, une énumération s'écrit avec des virgules et une seule conjonction à
 * la fin, jamais déversée en liste séparée par des virgules.
 *
 * Ce que ceci ne sait pas faire est le sujet du test de point de rupture placé
 * à côté.
 */

// A slot in a wording: an attribute name, or one of the grammar markers.
const SLOT = /\{(\w+)\}/g;

// The description is built block by block, in this order. Inside a block, the
// wordings say the same thing with different attributes and different words:
// the ones that can be filled are kept, the most informative of those win, and
// the product draws one of them.
export const BLOCKS = [
  [ // What the product is.
    '{name} : {un} {category} en {material}, pensé{e} pour {audience}.',
    '{name}, {un} {category} en {material} pour {audience}.',
    '{name} : {un} {category} en {material}.',
    '{name}, {un} {category} en {material}.',
    '{name} : {un} {category} pour {audience}.',
    '{name} : {un} {category}.',
    '{name}, {un} {category}.',
  ],
  [ // What it brings.
    'Point{s} fort{s} : {features}.',
    'Au programme : {features}.',
    'Côté équipement : {features}.',
  ],
  [ // What there is to choose.
    'Disponible en {colours}.',
    'À choisir en {colours}.',
    'Existe en {colours}.',
  ],
  [ // What you are promised.
    'Garanti{e} {warranty}.',
    'La garantie court sur {warranty}.',
    'Livré{e} avec {warranty} de garantie.',
  ],
];

// The three markers above that carry grammar rather than an attribute value.
const GRAMMAR = ['un', 'e', 's'];

/**
 * Render the description of one product.
 *
 * `product` maps an attribute name to a string or to an array of strings. Only
 * the attributes actually present are used. `gender` holds the grammatical
 * gender of the category noun and defaults to masculine, which is the only
 * piece of grammar a product database never stores and a French sentence
 * always needs.
 *
 * @param {Record<string, string|string[]>} product
 * @param {{conjunction?: string}} [options]
 */
export function describe(product, { conjunction = 'et' } = {}) {
  const { values, plural } = slotsOf(product, conjunction);
  const feminine = String(product.gender ?? 'm').toLowerCase().startsWith('f');
  const sentences = [];
  for (const wordings of BLOCKS) {
    const usable = usableWordings(wordings, values);
    if (usable.length > 0) {
      const drawn = usable[variant(String(product.name ?? ''), usable.length)];
      sentences.push(fill(drawn, values, plural, feminine));
    }
  }
  return sentences.join(' ');
}

/**
 * The wordings that can be filled, and among those the most informative.
 *
 * A wording is dropped as soon as one of its slots has no value. Of those that
 * remain, only the ones using the most attributes are kept: an attribute the
 * shop took the trouble to fill in must not be left out because the draw fell
 * on a shorter sentence. Several wordings usually tie, and that tie is where
 * the variety of this rung lives.
 */
function usableWordings(wordings, values) {
  const scored = [];
  for (const wording of wordings) {
    const slots = slotsIn(wording).filter((slot) => !GRAMMAR.includes(slot));
    if (slots.every((slot) => Object.hasOwn(values, slot))) scored.push([slots.length, wording]);
  }
  const best = Math.max(0, ...scored.map(([count]) => count));
  return scored.filter(([count]) => count === best).map(([, wording]) => wording);
}

/** The slot names a wording asks for, in order. */
function slotsIn(wording) {
  return [...wording.matchAll(SLOT)].map((match) => match[1]);
}

/**
 * Attribute values as insertable text, and the names that are plural.
 *
 * A value of null, a NULL column, is an attribute the product does not have:
 * it must not reach a sentence as the word « null ».
 */
function slotsOf(product, conjunction) {
  const values = {};
  const plural = new Set();
  for (const [key, raw] of Object.entries(product)) {
    let value = raw;
    if (value === null || value === undefined) continue;
    if (Array.isArray(value)) {
      const items = value.filter((item) => item !== null && item !== undefined).map((item) => String(item).trim()).filter(Boolean);
      if (items.length > 1) plural.add(key);
      value = enumerate(items, conjunction);
    }
    if (String(value).trim()) values[key] = String(value).trim();
  }
  return { values, plural };
}

/** « ardoise, sable et bronze » : commas, then the conjunction once. */
function enumerate(items, conjunction) {
  if (items.length < 2) return items[0] ?? '';
  return `${items.slice(0, -1).join(', ')} ${conjunction} ${items.at(-1)}`;
}

/** Write the attributes and the grammar markers into one wording. */
function fill(wording, values, plural, feminine) {
  const slots = slotsIn(wording);
  // The grammar markers are written last, so an attribute called `s` or `e`
  // cannot quietly take their place.
  const filled = {
    ...values,
    un: feminine ? 'une' : 'un',
    e: feminine ? 'e' : '',
    s: slots.some((slot) => plural.has(slot)) ? 's' : '',
  };
  return wording.replace(SLOT, (_, slot) => filled[slot]);
}

/**
 * Draw one wording out of `count`, the same one for the same product.
 *
 * Summing the code points is deliberately crude. It has one job: give the same
 * answer as the Python version of this snippet, so a catalogue rendered by
 * either reads identically. The name is composed first (NFC), so an accent
 * typed as two code points draws the same wording as one.
 */
function variant(seed, count) {
  let total = 0;
  for (const char of seed.normalize('NFC')) total += char.codePointAt(0);
  return total % count;
}

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é : les attributs produit ne quittent pas votre infrastructure
  • Ne vous dispense pas de répondre des allégations que vous publiez ; ici, chaque phrase que le code peut produire a été écrite et relue avant la mise en ligne

Point de rupture

La répétition, et elle se compte. Le test rend deux cents fiches complètes, efface de chaque description les valeurs propres au produit, et dénombre les motifs de phrase qui restent : douze, pour deux cents articles, le moins fréquent revenant seize fois. Sur le catalogue écrit à la main, dont les enregistrements ne portent pas tous les mêmes attributs, les phrases d’ouverture tombent dans neuf motifs pour vingt produits et le plus courant en couvre six — et cette variété-là vient des trous du catalogue, pas du gabarit.

Quand passer au niveau suivant

Vous ouvrez deux pages de votre boutique à la suite et vous reconnaissez la phrase avant de reconnaître le produit.

N1Niveau sans objet ici

Niveau sans objet ici

Modèle classique légerN1

Un modèle classique léger classe, note ou étiquette : écrire n’est pas ce pour quoi il est fait. Aucun ne produit de la prose qu’une boutique puisse publier, et pour un texte fabriqué en recombinant des morceaux de phrases, le gabarit de N0 fait mieux — ses phrases, au moins, ont été écrites par quelqu’un.

N2Petit modèle génératif auto-hébergé (BARThez affiné sur vos descriptions déjà publiées)modéré · Une seconde

Petit modèle génératif auto-hébergé (BARThez affiné sur vos descriptions déjà publiées)

Petit modèle spécialisé auto-hébergéN2modéréUne seconde

Python

snippets/write-product-descriptions/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.
"""
Rédiger une description produit avec un petit modèle génératif auto-hébergé.

Niveau N2. Un point de contrôle de séquence à séquence qui vit sur votre
propre disque, affiné sur les descriptions que votre boutique a déjà
publiées. Rien ne sort de vos machines et rien n'est facturé au compteur.

L'affinage n'est pas dans ce fichier, et c'est le travail de ce niveau.
Partez d'un modèle qui écrit déjà le français : BARThez, un modèle de
séquence à séquence français de la forme de BART, préentraîné sur du texte
français brut, 165 M de paramètres dans sa taille de base, sous licence
Apache 2.0. Le jeu d'entraînement, c'est une paire par description que votre
catalogue a déjà publiée — le dossier sur une ligne, comme « _source »
l'écrit plus bas, et la description publiée en cible. Vous n'achetez pas un
corpus : vous vous servez de celui que votre boutique a écrit. Ce qui en
ressort est un point de contrôle à vous, celui que « CHECKPOINT » désigne.

Autour du modèle, tout ce niveau est du code dont vous héritez : la ligne de
source que le modèle a été affiné à lire, le plafond de taille, la reprise,
la phrase qu'un petit modèle laisse à moitié finie quand son budget de jetons
s'épuise, et le contrôle final.

Ce contrôle est la raison d'être de ce fichier. Le challenge de génération
E2E a trouvé que les modèles de séquence à séquence sans contrôle sémantique
rendent souvent mal les attributs qu'on leur donne. La copie est donc relue contre le dossier, terme
à terme, et tout ce que le dossier n'étaye pas est refusé. Une boutique qui promet ce qu'elle ne vend pas a un
problème juridique, pas un problème de style.
"""

from __future__ import annotations

import re
import unicodedata

# One product record, written on one line. Longer than that, it is not a
# product record, and the model only wanders further from it.
MAX_CHARACTERS = 600

# Shorter than that, the model handed back a fragment and not a description.
MIN_CHARACTERS = 40


class DescriptionUnavailable(Exception):
    """The model failed, or answered something no shop can publish."""


class UngroundedDescription(DescriptionUnavailable):
    """The copy claims an attribute the product record does not carry."""


# What to fine-tune from, for a French catalogue. Read its card before you
# commit to it: language, licence, size. This one says French, Apache 2.0,
# 165 M parameters.
BASE_CHECKPOINT = "moussaKam/barthez"

# Where your fine-tuned weights are: the output of that training, not a model
# to download.
CHECKPOINT = "./models/catalogue-copy"


class LocalCopywriter:
    """The real model: a fine-tuned checkpoint on your disk, loaded once."""

    def __init__(self, checkpoint: str = CHECKPOINT) -> None:
        # transformers 5 removed the "text2text-generation" pipeline: the
        # tokenizer and the model are called directly.
        from transformers import AutoModelForSeq2SeqLM, AutoTokenizer  # a large local install

        self._tokenizer = AutoTokenizer.from_pretrained(checkpoint)
        self._model = AutoModelForSeq2SeqLM.from_pretrained(checkpoint)

    def generate(self, source: str, **options) -> str:
        output = self._model.generate(**self._tokenizer([source], return_tensors="pt"), **options)
        return self._tokenizer.decode(output[0], skip_special_tokens=True)


def describe(product: dict, model=None, *, vocabulary=(), attempts: int = 2) -> str:
    """
    Write the description of one product.

    `model` is injected so this can be tested without the checkpoint; in
    production it defaults to the real one above.

    `vocabulary` is the attribute words your catalogue uses — materials,
    finishes, features, claims. It is what makes the grounding check possible:
    a word of that list found in the copy and nowhere in the record is an
    invention. An empty vocabulary switches the check off, which is a decision,
    not a default to leave alone.
    """
    source = _source(product)
    if not source:
        raise ValueError("empty product record: nothing to describe")
    if len(source) > MAX_CHARACTERS:
        raise ValueError(f"product record longer than {MAX_CHARACTERS} characters")
    model = model or LocalCopywriter()

    description = _whole_sentences(_generate(model, source, attempts))
    if len(description) < MIN_CHARACTERS:
        raise DescriptionUnavailable("the model answered a fragment")

    invented = [term for term in vocabulary if _states(description, term) and not _states(source, term)]
    if invented:
        raise UngroundedDescription(", ".join(invented))
    return description


def _source(product: dict) -> str:
    """The shape the model was fine-tuned on: one line of « field: value »."""
    fields = []
    for key, value in product.items():
        items = value if isinstance(value, (list, tuple)) else [value]
        joined = ", ".join(str(item) for item in items if item is not None)  # None: a NULL column
        if joined.strip():
            fields.append(f"{key}: {joined.strip()}")
    return " | ".join(fields)


def _generate(model, source: str, attempts: int) -> str:
    """Retry: on a machine that also serves the shop, the first call fails."""
    last_error: Exception | None = None
    for _ in range(attempts):
        try:
            text = model.generate(source, max_new_tokens=90, num_beams=4)
        except Exception as error:  # noqa: BLE001 - any model failure is retried
            last_error = error
            continue
        if isinstance(text, str):
            return text
        last_error = TypeError("the model returned no text")
    raise DescriptionUnavailable(str(last_error))


def _whole_sentences(text) -> str:
    """
    Keep only what the model finished saying.

    A small model stops when its budget runs out, mid-sentence and sometimes
    mid-word. Publishing that is worse than publishing nothing at all. A
    sentence ends on a mark followed by the end of the text or by a capital,
    so « 1.2 kg » is not an end; « M. Dupont » still is.
    """
    text = " ".join(text.split())
    end = 0
    for mark in re.finditer(r"[.!?]", text):
        after = text[mark.end():mark.end() + 2]
        if after == "" or (after[0] == " " and after[1:].isupper()):
            end = mark.end()
    return text[:end]


# A term right after one of these words is denied, not stated: « non étanche ».
NEGATIONS = frozenset({"non", "pas", "sans", "ni", "aucun", "aucune"})


def _states(text: str, term: str) -> bool:
    """
    Whether the text states the term: whole words, case and accents set aside,
    each word allowed the agreement endings e, s and es (« garantie à vie »
    states « garanti à vie »), and not right after a negation.
    """
    folded = _fold(text)
    words = r"\s+".join(re.escape(word) + "(?:e|s|es)?" for word in _fold(term).split())
    for found in re.finditer(rf"(?<!\w){words}(?!\w)", folded):
        before = re.findall(r"\w+", folded[: found.start()])
        if not before or before[-1] not in NEGATIONS:
            return True
    return False


def _fold(text: str) -> str:
    """Lowercase and drop accents, so « Étanche » meets « etanche »."""
    letters = unicodedata.normalize("NFD", str(text).lower())
    return "".join(char for char in letters if not unicodedata.combining(char))

JavaScript

snippets/write-product-descriptions/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.
/**
 * Rédiger une description produit avec un petit modèle génératif auto-hébergé.
 *
 * Niveau N2. Un point de contrôle de séquence à séquence qui vit sur votre
 * propre disque, affiné sur les descriptions que votre boutique a déjà
 * publiées. Rien ne sort de vos machines et rien n'est facturé au compteur.
 *
 * L'affinage n'est pas dans ce fichier, et c'est le travail de ce niveau.
 * Partez d'un modèle qui écrit déjà le français : BARThez, un modèle de
 * séquence à séquence français de la forme de BART, préentraîné sur du texte
 * français brut, 165 M de paramètres dans sa taille de base, sous licence
 * Apache 2.0. Le jeu d'entraînement, c'est une paire par description que votre
 * catalogue a déjà publiée — le dossier sur une ligne, comme « _source »
 * l'écrit plus bas, et la description publiée en cible. Vous n'achetez pas un
 * corpus : vous vous servez de celui que votre boutique a écrit. Ce qui en
 * ressort est un point de contrôle à vous, celui que « CHECKPOINT » désigne.
 *
 * Autour du modèle, tout ce niveau est du code dont vous héritez : la ligne de
 * source que le modèle a été affiné à lire, le plafond de taille, la reprise,
 * la phrase qu'un petit modèle laisse à moitié finie quand son budget de jetons
 * s'épuise, et le contrôle final.
 *
 * Ce contrôle est la raison d'être de ce fichier. Le challenge de génération
 * E2E a trouvé que les modèles de séquence à séquence sans contrôle sémantique
 * rendent souvent mal les attributs qu'on leur donne. La copie est donc relue contre le dossier, terme
 * à terme, et tout ce que le dossier n'étaye pas est refusé. Une boutique qui promet ce qu'elle ne vend pas a un
 * problème juridique, pas un problème de style.
 */

// One product record, written on one line. Longer than that, it is not a
// product record, and the model only wanders further from it.
export const MAX_CHARACTERS = 600;

// Shorter than that, the model handed back a fragment and not a description.
export const MIN_CHARACTERS = 40;

export class DescriptionUnavailable extends Error {}

export class UngroundedDescription extends DescriptionUnavailable {}

/**
 * The real model: a fine-tuned checkpoint on your disk, loaded once.
 * Transformers.js runs ONNX weights: convert the checkpoint first, with Optimum.
 */
// What to fine-tune from, for a French catalogue. Read its card before you
// commit to it: language, licence, size. This one says French, Apache 2.0,
// 165 M parameters.
export const BASE_CHECKPOINT = 'moussaKam/barthez';

// Where your fine-tuned weights are: the output of that training, not a model
// to download.
export const CHECKPOINT = './models/catalogue-copy';

export class LocalCopywriter {
  static async load(checkpoint = CHECKPOINT) {
    const { pipeline } = await import('@huggingface/transformers'); // a large local install
    return new LocalCopywriter(await pipeline('text2text-generation', checkpoint));
  }

  constructor(write) {
    this.write = write;
  }

  async generate(source, options = {}) {
    const [answer] = await this.write(source, options);
    return answer.generated_text;
  }
}

/**
 * Write the description of one product.
 *
 * `model` is injected so this can be tested without the checkpoint; in
 * production it defaults to the real one above.
 *
 * `vocabulary` is the attribute words your catalogue uses — materials,
 * finishes, features, claims. It is what makes the grounding check possible: a
 * word of that list found in the copy and nowhere in the record is an
 * invention. An empty vocabulary switches the check off, which is a decision,
 * not a default to leave alone.
 *
 * @param {Record<string, string|string[]>} product
 * @param {{generate: Function}} [model]
 * @param {{vocabulary?: string[], attempts?: number}} [options]
 */
export async function describe(product, model, { vocabulary = [], attempts = 2 } = {}) {
  const source = sourceLine(product);
  if (source === '') throw new RangeError('empty product record: nothing to describe');
  // Characters are counted as code points, as in Python: an emoji is one.
  if ([...source].length > MAX_CHARACTERS) {
    throw new RangeError(`product record longer than ${MAX_CHARACTERS} characters`);
  }
  const copywriter = model ?? (await LocalCopywriter.load());

  const description = wholeSentences(await generate(copywriter, source, attempts));
  if (description.length < MIN_CHARACTERS) {
    throw new DescriptionUnavailable('the model answered a fragment');
  }

  const invented = vocabulary.filter((term) => states(description, term) && !states(source, term));
  if (invented.length > 0) throw new UngroundedDescription(invented.join(', '));
  return description;
}

/** The shape the model was fine-tuned on: one line of « field: value ». */
function sourceLine(product) {
  const fields = [];
  for (const [key, value] of Object.entries(product)) {
    const items = (Array.isArray(value) ? value : [value]).filter((item) => item != null); // null: a NULL column
    const joined = items.map(String).join(', ');
    if (joined.trim()) fields.push(`${key}: ${joined.trim()}`);
  }
  return fields.join(' | ');
}

/** Retry: on a machine that also serves the shop, the first call fails. */
async function generate(model, source, attempts) {
  let lastError;
  for (let i = 0; i < attempts; i += 1) {
    let text;
    try {
      text = await model.generate(source, { max_new_tokens: 90, num_beams: 4 });
    } catch (error) {
      lastError = error;
      continue;
    }
    if (typeof text === 'string') return text;
    lastError = new TypeError('the model returned no text');
  }
  throw new DescriptionUnavailable(String(lastError));
}

/**
 * Keep only what the model finished saying.
 *
 * A small model stops when its budget runs out, mid-sentence and sometimes
 * mid-word. Publishing that is worse than publishing nothing at all. A
 * sentence ends on a mark followed by the end of the text or by a capital, so
 * « 1.2 kg » is not an end; « M. Dupont » still is.
 */
function wholeSentences(text) {
  const tidy = text.split(/\s+/).filter(Boolean).join(' ');
  let end = 0;
  for (const mark of tidy.matchAll(/[.!?]/g)) {
    const after = tidy.slice(mark.index + 1, mark.index + 3);
    if (after === '' || /^ \p{Lu}$/u.test(after)) end = mark.index + 1;
  }
  return tidy.slice(0, end);
}

// A term right after one of these words is denied, not stated: « non étanche ».
const NEGATIONS = new Set(['non', 'pas', 'sans', 'ni', 'aucun', 'aucune']);

/**
 * Whether the text states the term: whole words, case and accents set aside,
 * each word allowed the agreement endings e, s and es (« garantie à vie »
 * states « garanti à vie »), and not right after a negation.
 */
function states(text, term) {
  const folded = fold(text);
  const words = fold(term).split(/\s+/).filter(Boolean)
    .map((word) => `${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:e|s|es)?`);
  const pattern = new RegExp(`(?<![\\p{L}\\p{N}_])${words.join('\\s+')}(?![\\p{L}\\p{N}_])`, 'gu');
  for (const found of folded.matchAll(pattern)) {
    const before = folded.slice(0, found.index).match(/[\p{L}\p{N}_]+/gu) ?? [];
    if (!NEGATIONS.has(before.at(-1))) return true;
  }
  return false;
}

/** Lowercase and drop accents, so « Étanche » meets « etanche ». */
function fold(text) {
  return String(text)
    .toLowerCase()
    .normalize('NFD')
    .replace(/\p{Diacritic}/gu, '');
}

Ce que ça implique

  • Vos données restent chez vous
  • Résultat toujours identique
  • Difficile à tester
  • Dépend d’une bibliothèque
  • Empreinte modérée

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

  • Traitement sur votre infrastructure : ni le dossier produit ni le texte n’en sortent
  • Le corpus d’affinage est fait de vos descriptions déjà publiées, avec la titularité des droits sur ces textes que cela suppose
  • Licence et provenance du point de contrôle à établir avant mise en production : celui d’où part cette fiche, BARThez, est publié sous licence Apache 2.0
  • Ne vous dispense pas de répondre des allégations que vous publiez : le contrôle d’ancrage ne couvre que les termes de la liste que vous tenez, et cette liste relève de qui répond des allégations — responsable qualité ou juriste —, pas de qui écrit le code

Point de rupture

Le modèle affirme ce que le dossier ne dit pas. Le test fait rendre au double local une phrase juste de ton et de grammaire — la toile recyclée du sac y est « entièrement étanche » — pour un article dont aucun attribut ne parle d’étanchéité. L’extrait refuse la copie au lieu de la publier, y compris quand le dossier dit « non étanche » ou que la copie écrit « étanches », mais seulement pour les termes que vous avez listés : le test suivant montre la même phrase passant intacte avec un vocabulaire vide.

Quand passer au niveau suivant

Une gamme nouvelle arrive, et le corpus de descriptions publiées qu’un nouvel affinage réclamerait n’existe pas encore.

Pour aller plus loin

Source des chiffres

Métadonnées