Catalogue Générer

Rédiger des descriptions produit

Écrire le texte de présentation de chaque article d'un catalogue à partir de ses caractéristiques.

RecommandéN3 Révisée le

Récapitulatif des barreaux
Barreau Approche Coût Latence Données Déterministe Verdict
Règle et algorithme classique N0 — Règle et algorithme classique Gabarits à trous, avec accord grammatical et repli sur les attributs présents Nul <1 ms Rien ne sort Oui
Modèle classique léger N1 — Modèle classique léger Barreau absent 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.
Petit modèle spécialisé auto-hébergé N2 — Petit modèle spécialisé auto-hébergé Petit modèle génératif auto-hébergé, affiné sur vos descriptions déjà publiées Modéré ~1 s Reste dans votre infrastructure Oui
API de LLM généraliste N3 — API de LLM généraliste Rédaction par appel à un modèle généraliste, avec contrôle d'ancrage sur le dossier produit Élevé ~1 s Part chez un tiers Non Recommandé

N0 — Règle et algorithme classique Règle et algorithme classique

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

Coût
Nul
Latence
<1 ms

Preuve d’exécution : Code exécuté tel quel

Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.

Python

snippets/write-product-descriptions/n0.py
"""
Write a product description by filling slot templates.

Rung N0. Deterministic, standard library only. A template is not the poor
relation of a model: it never claims a feature the product does not have, it
renders in the time it takes to read a dictionary, and every sentence it can
possibly produce was written and approved by a human being before it shipped.

Three things separate a template that survives a real catalogue from the one
everybody writes in ten minutes and throws away in a week.

First, a missing attribute must not leave a hole in a sentence. Each block of
the description offers several wordings; only those whose slots are all filled
are eligible, and the ones using the most attributes are preferred. A product
with no colour list simply gets no colour sentence, instead of « Disponible
en . ».

Second, agreement is not optional in a sales page. The markers `{un}`, `{e}`
and `{s}` carry the grammatical gender of the category noun and the number of
the enumerated list, so « Garantie deux ans » and « Points forts » come out
right without a second template per case.

Third, an enumeration is written with commas and one conjunction at the end,
never dumped as a comma-separated list.

What this cannot do is the subject of the breaking-point test next to it.
"""

from __future__ import annotations

import re

# 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", "")), 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."""
    values: dict[str, str] = {}
    plural: set[str] = set()
    for key, value in product.items():
        if isinstance(value, (list, tuple)):
            items = [str(item).strip() for item in value if 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.
    """
    return sum(ord(char) for char in seed) % count

JavaScript

snippets/write-product-descriptions/n0.js
/**
 * Write a product description by filling slot templates.
 *
 * Rung N0. Deterministic, no dependency. A template is not the poor relation
 * of a model: it never claims a feature the product does not have, it renders
 * in the time it takes to read a dictionary, and every sentence it can
 * possibly produce was written and approved by a human being before it
 * shipped.
 *
 * Three things separate a template that survives a real catalogue from the one
 * everybody writes in ten minutes and throws away in a week.
 *
 * First, a missing attribute must not leave a hole in a sentence. Each block
 * of the description offers several wordings; only those whose slots are all
 * filled are eligible, and the ones using the most attributes are preferred. A
 * product with no colour list simply gets no colour sentence, instead of
 * « Disponible en . ».
 *
 * Second, agreement is not optional in a sales page. The markers `{un}`, `{e}`
 * and `{s}` carry the grammatical gender of the category noun and the number
 * of the enumerated list, so « Garantie deux ans » and « Points forts » come
 * out right without a second template per case.
 *
 * Third, an enumeration is written with commas and one conjunction at the end,
 * never dumped as a comma-separated list.
 *
 * What this cannot do is the subject of the breaking-point test next to it.
 */

// 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. */
function slotsOf(product, conjunction) {
  const values = {};
  const plural = new Set();
  for (const [key, raw] of Object.entries(product)) {
    let value = raw;
    if (Array.isArray(value)) {
      const items = value.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.
 */
function variant(seed, count) {
  let total = 0;
  for (const char of seed) total += char.codePointAt(0);
  return total % count;
}

Risques

Sortie de données
Rien ne sort
Déterminisme
Oui
Testabilité
Testable unitairement
Dépendance fournisseur
Aucune
Empreinte
Négligeable
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 monter d’un barreau

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

N1 — Modèle classique léger Modèle classique léger

Barreau absent

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.

N2 — Petit modèle spécialisé auto-hébergé Petit modèle spécialisé auto-hébergé

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

Coût
Modéré
Latence
~1 s

Preuve d’exécution : Code exécuté, service externe simulé

Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local. Ce qui est vérifié : la requête envoyée, la réponse décodée et les cas d’erreur. Ce qui ne l’est pas : la qualité de la réponse du modèle.

Python

snippets/write-product-descriptions/n2.py
"""
Write a product description with a small self-hosted generative model.

Rung N2. A sequence-to-sequence checkpoint that lives on your own disk,
fine-tuned on the descriptions your shop has already published, so that it
writes in your voice rather than in the average voice of the web. Nothing
leaves your machines and nothing is metered.

The prose is freer than a template's, and everything else on this rung is code
you now own: the source line the model was fine-tuned to read, the size cap,
the retry, the sentence a small model leaves half-finished when its token
budget runs out, and the check at the end.

That check is the point of this file. A generative model writes what sounds
right. Handed a bag, it will sooner or later call it waterproof, because the
sentences it learnt from ended that way, and nothing inside it distinguishes an
attribute of this product from a plausible attribute. So the copy is read back
against the record, term by term, and anything the record does not support is
refused. A shop that promises what it does not sell has a legal problem, not a
style problem.
"""

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


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

    def __init__(self, checkpoint: str = "./models/catalogue-copy") -> None:
        from transformers import pipeline  # a large local install

        self._write = pipeline("text2text-generation", model=checkpoint)

    def generate(self, source: str, **options) -> str:
        return self._write(source, **options)[0]["generated_text"]


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.
    """
    model = model or LocalCopywriter()
    source = _source(product)
    if len(source) > MAX_CHARACTERS:
        raise ValueError(f"product record longer than {MAX_CHARACTERS} characters")

    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 _says(description, term) and not _says(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():
        joined = ", ".join(str(item) for item in value) if isinstance(value, (list, tuple)) else str(value)
        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:
            return model.generate(source, max_new_tokens=90, num_beams=4)
        except Exception as error:  # noqa: BLE001 - any model failure is retried
            last_error = error
    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.
    """
    text = " ".join(str(text).split())
    end = max(text.rfind(mark) for mark in ".!?")
    return text[: end + 1] if end >= 0 else ""


def _says(text: str, term: str) -> bool:
    """Whole-word search, case and accents set aside."""
    return re.search(rf"\b{re.escape(_fold(term))}\b", _fold(text)) is not None


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.js
/**
 * Write a product description with a small self-hosted generative model.
 *
 * Rung N2. A sequence-to-sequence checkpoint that lives on your own disk,
 * fine-tuned on the descriptions your shop has already published, so that it
 * writes in your voice rather than in the average voice of the web. Nothing
 * leaves your machines and nothing is metered.
 *
 * The prose is freer than a template's, and everything else on this rung is
 * code you now own: the source line the model was fine-tuned to read, the size
 * cap, the retry, the sentence a small model leaves half-finished when its
 * token budget runs out, and the check at the end.
 *
 * That check is the point of this file. A generative model writes what sounds
 * right. Handed a bag, it will sooner or later call it waterproof, because the
 * sentences it learnt from ended that way, and nothing inside it distinguishes
 * an attribute of this product from a plausible attribute. So the copy is read
 * back against the record, term by term, and anything the record does not
 * support is refused. A shop that promises what it does not sell has a legal
 * problem, not a style problem.
 */

// 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. */
export class LocalCopywriter {
  static async load(checkpoint = './models/catalogue-copy') {
    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 copywriter = model ?? (await LocalCopywriter.load());
  const source = sourceLine(product);
  if (source.length > MAX_CHARACTERS) {
    throw new RangeError(`product record longer than ${MAX_CHARACTERS} characters`);
  }

  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) => says(description, term) && !says(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 joined = Array.isArray(value) ? value.join(', ') : String(value);
    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) {
    try {
      return await model.generate(source, { max_new_tokens: 90, num_beams: 4 });
    } catch (error) {
      lastError = error;
    }
  }
  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.
 */
function wholeSentences(text) {
  const tidy = String(text).split(/\s+/).filter(Boolean).join(' ');
  const end = Math.max(...['.', '!', '?'].map((mark) => tidy.lastIndexOf(mark)));
  return end >= 0 ? tidy.slice(0, end + 1) : '';
}

/** Whole-word search, case and accents set aside. */
function says(text, term) {
  const escaped = fold(term).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  return new RegExp(`\\b${escaped}\\b`).test(fold(text));
}

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

Risques

Sortie de données
Reste dans votre infrastructure
Déterminisme
Oui
Testabilité
Difficilement testable
Dépendance fournisseur
Bibliothèque
Empreinte
Modérée
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
  • 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

Point de rupture

Le modèle affirme ce que le dossier ne dit pas. Le test lui fait rendre 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é : rien, dans le modèle, ne sépare un attribut de ce produit d'un attribut qui va bien dans une phrase de cette forme. L'extrait refuse la copie au lieu de la publier, 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 monter d’un barreau

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

N3 — API de LLM généraliste API de LLM généraliste Recommandé

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

Coût
Élevé
Latence
~1 s

Preuve d’exécution : Code exécuté, service externe simulé

Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local. Ce qui est vérifié : la requête envoyée, la réponse décodée et les cas d’erreur. Ce qui ne l’est pas : la qualité de la réponse du modèle.

Python

snippets/write-product-descriptions/n3.py
"""
Write a product description by asking a general-purpose model.

Rung N3. On most entries of this site this rung is the expensive answer to a
question that did not need it. Here it is the one that wins: turning a bag of
attributes into prose that reads differently for every product is precisely
what a general-purpose model does better than anything below it, and no amount
of template writing closes that gap.

What it costs is visible in the code, and none of it is the model's doing: the
request, the size cap, the retry, an answer that is only probably JSON, and a
temperature above zero — because variety is the thing being bought here, so
two runs on the same product will not agree, and nothing can be reviewed once
and trusted afterwards.

Which is why the last check is the one from the rung below. A model that writes
freely also claims freely. The copy is read back against the record, term by
term, and anything the record does not support is refused rather than
published.
"""

from __future__ import annotations

import json
import re
import unicodedata

# The instructions are written in the language of the shop: a model asked in
# English for French copy answers in French with an English cadence.
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."""


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.
    """
    if client is None:  # pragma: no cover - needs a key and a network
        from openai import OpenAI

        client = OpenAI()

    attributes = _attributes(product)
    if len(attributes) > MAX_CHARACTERS:
        raise ValueError(f"product record longer than {MAX_CHARACTERS} characters")

    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 _says(description, term) and not _says(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():
        joined = ", ".join(str(item) for item in value) if isinstance(value, (list, tuple)) else str(value)
        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 = json.loads(client.complete(prompt=prompt, temperature=temperature))
            written = answer.get("description", "") if isinstance(answer, dict) else ""
            if 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 _says(text: str, term: str) -> bool:
    """Whole-word search, case and accents set aside."""
    return re.search(rf"\b{re.escape(_fold(term))}\b", _fold(text)) is not None


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.js
/**
 * Write a product description by asking a general-purpose model.
 *
 * Rung N3. On most entries of this site this rung is the expensive answer to a
 * question that did not need it. Here it is the one that wins: turning a bag
 * of attributes into prose that reads differently for every product is
 * precisely what a general-purpose model does better than anything below it,
 * and no amount of template writing closes that gap.
 *
 * What it costs is visible in the code, and none of it is the model's doing:
 * the request, the size cap, the retry, an answer that is only probably JSON,
 * and a temperature above zero — because variety is the thing being bought
 * here, so two runs on the same product will not agree, and nothing can be
 * reviewed once and trusted afterwards.
 *
 * Which is why the last check is the one from the rung below. A model that
 * writes freely also claims freely. The copy is read back against the record,
 * term by term, and anything the record does not support is refused rather
 * than published.
 */

// The instructions are written in the language of the shop: a model asked in
// English for French copy answers in French with an English cadence.
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 {}

/**
 * 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 } = {},
) {
  if (!client) {
    // Needs a key and a network, so it is never reached in the tests.
    const { OpenAI } = await import('openai');
    client = new OpenAI();
  }

  const attributes = attributeLines(product);
  if (attributes.length > MAX_CHARACTERS) {
    throw new RangeError(`product record longer than ${MAX_CHARACTERS} characters`);
  }

  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) => says(description, term) && !says(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 joined = Array.isArray(value) ? value.join(', ') : String(value);
    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 = JSON.parse(await client.complete({ prompt, temperature }));
      const written = answer && typeof answer === 'object' ? (answer.description ?? '') : '';
      if (String(written).trim()) return String(written).split(/\s+/).filter(Boolean).join(' ');
      lastError = new Error('the model answered without a description');
    } catch (error) {
      lastError = error;
    }
  }
  throw new DescriptionUnavailable(String(lastError));
}

/** Whole-word search, case and accents set aside. */
function says(text, term) {
  const escaped = fold(term).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  return new RegExp(`\\b${escaped}\\b`).test(fold(text));
}

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

Risques

Sortie de données
Part chez un tiers
Déterminisme
Non
Testabilité
Difficilement testable
Dépendance fournisseur
Fournisseur externe
Empreinte
Élevée
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 lui fait écrire que le sac est « garanti à vie » quand le dossier dit deux ans : aucune consigne, aucune température ne retire ce risque, puisque rien dans le modèle ne sépare un attribut de ce produit d'un attribut qui va bien dans une phrase de cette forme. Ce qui l'attrape est le contrôle du barreau 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 : deux appels sur le même produit rendent deux textes, donc ce que vous avez relu hier n'est pas ce que le client lit aujourd'hui.

Quand monter d’un barreau

Il n'y a pas de barreau au-dessus.

Le verdict

RecommandéN3

N3, sans détour : ce qu'on achète ici est la variété, et c'est précisément ce que le barreau du bas ne fabrique pas — douze motifs de phrase pour deux cents produits, mesurés par le test de N0, et le vingt-et-unième gabarit s'écrit à la main par quelqu'un qui en a déjà écrit vingt. N2 écrit bien pour les gammes qu'il a vues, mais il réclame d'avoir déjà publié le catalogue qu'on lui demande d'écrire, et il tombe sur le même défaut que N3 : il affirme ce que le dossier ne dit pas. Le prix est réel, un texte facturé au jeton par article, vos dossiers produit chez un tiers, et une copie qui change à chaque appel donc jamais relue une bonne fois ; ce qu'il achète, c'est une boutique qui ne se lit pas comme un formulaire. Si votre catalogue tient sur trente références qu'un rédacteur écrit une fois pour toutes, aucun barreau de cette fiche n'est la réponse.

Pour aller plus loin

Métadonnées