Write product descriptions
Write the sales text for every item in a catalogue, starting from its characteristics.
RecommendedN3 Reviewed on
| Rung | Approach | Cost | Latency | Data | Deterministic | Verdict |
|---|---|---|---|---|---|---|
| N0 — Rule and classic algorithm | Slot templates, with grammatical agreement and a fallback on the attributes present | None | <1 ms | Nothing leaves | Yes | |
| N1 — Lightweight classic model | Rung not applicable A lightweight classic model classifies, scores or tags: writing is not what it is for. None of them produces prose a shop can publish, and for text assembled by recombining fragments of sentences, the N0 template does better — its sentences were at least written by somebody. | |||||
| N2 — Small self-hosted specialised model | Small self-hosted generative model, fine-tuned on the descriptions you have already published | Moderate | ~1 s | Stays on your infrastructure | Yes | |
| N3 — General-purpose LLM API | Copywriting through a general-purpose model, with a grounding check against the product record | High | ~1 s | Goes to a third party | No | Recommended |
N0 — Rule and classic algorithm Rule and classic algorithm
Slot templates, with grammatical agreement and a fallback on the attributes present
- Cost
- None
- Latency
<1 ms
Proof of execution : Code runs as shown
This snippet runs with its real dependencies, and its test runs on every build of the site.
Python
"""
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) % countJavaScript
/**
* 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;
}Risks
- Data leaving
- Nothing leaves
- Determinism
- Yes
- Testability
- Unit testable
- Vendor dependency
- None
- Footprint
- Negligible
- Regulatory scope
-
- No specific scope added: the product attributes never leave your infrastructure
- Does not excuse you from answering for the claims you publish; here, every sentence the code can produce was written and reviewed before it shipped
Breaking point
Repetition, and it is counted. The test renders two hundred complete records, blanks each product's own values out of its description, and tallies the sentence frames left behind: twelve, across two hundred items, the rarest of them used sixteen times. On the hand-written catalogue, whose records do not all carry the same attributes, the opening sentences fall into nine frames for twenty products and the commonest covers six of them — and that much variety comes from the holes in the data, not from the template.
When to move up a rung
You open two pages of your shop in a row and recognise the sentence before you recognise the product.
N1 — Lightweight classic model Lightweight classic model
Rung not applicable
A lightweight classic model classifies, scores or tags: writing is not what it is for. None of them produces prose a shop can publish, and for text assembled by recombining fragments of sentences, the N0 template does better — its sentences were at least written by somebody.
N2 — Small self-hosted specialised model Small self-hosted specialised model
Small self-hosted generative model, fine-tuned on the descriptions you have already published
- Cost
- Moderate
- Latency
~1 s
Proof of execution : Code runs, external service simulated
This snippet runs on every build of the site, but its test replaces the external service with a local double. What is verified: the request sent, the response decoded, and the error paths. What is not: how good the model’s answer is.
Python
"""
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
/**
* 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, '');
}Risks
- Data leaving
- Stays on your infrastructure
- Determinism
- Yes
- Testability
- Hard to test
- Vendor dependency
- Library
- Footprint
- Moderate
- Regulatory scope
-
- Processing on your own infrastructure: neither the product record nor the copy leaves it
- The fine-tuning corpus is made of the descriptions you have already published, with the ownership of those texts that implies
- Licence and provenance of the checkpoint to be established before production use
- Does not excuse you from answering for the claims you publish: the grounding check only covers the terms on the list you maintain
Breaking point
The model asserts what the record does not say. The test has it hand back a sentence that is right in tone and in grammar — the bag's recycled canvas is « entièrement étanche », fully waterproof — for an item whose attributes never mention water: nothing inside the model separates an attribute of this product from an attribute that sits well in a sentence of that shape. The snippet refuses the copy instead of publishing it, but only for the terms you listed: the next test shows the same sentence going through untouched on an empty vocabulary.
When to move up a rung
A new range arrives, and the corpus of published descriptions a fresh fine-tune would call for does not exist yet.
N3 — General-purpose LLM API General-purpose LLM API Recommended
Copywriting through a general-purpose model, with a grounding check against the product record
- Cost
- High
- Latency
~1 s
Proof of execution : Code runs, external service simulated
This snippet runs on every build of the site, but its test replaces the external service with a local double. What is verified: the request sent, the response decoded, and the error paths. What is not: how good the model’s answer is.
Python
"""
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
/**
* 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, '');
}Risks
- Data leaving
- Goes to a third party
- Determinism
- No
- Testability
- Hard to test
- Vendor dependency
- External provider
- Footprint
- High
- Regulatory scope
-
- Transfer of your product records to a processor, unreleased items included, with the contractual framing that implies
- Processing location to be confirmed with the provider
- Does not excuse you from answering for the claims you publish: a sentence written by the model binds you as one written by you
Breaking point
The model promises what the shop does not sell, with the same confidence as the true sentence before it. The test has it write that the bag is « garanti à vie », guaranteed for life, where the record says two years: no instruction and no temperature removes that risk, because nothing in the model separates an attribute of this product from an attribute that sits well in a sentence of that shape. What catches it is the check from the rung below, term by term against the record, and it is worth exactly what your list is worth. On top of that comes the thing you are buying: two calls on the same product return two texts, so what you reviewed yesterday is not what the customer reads today.
When to move up a rung
There is no rung above this one.
The verdict
RecommendedN3
N3, and no apology for it: variety is what you are buying here, and variety is exactly what the bottom rung cannot manufacture — twelve sentence frames for two hundred products, measured by the N0 test, and the twenty-first template is written by hand by somebody who has already written twenty. N2 writes well for the ranges it has seen, but it demands that you already published the catalogue you are asking it to write, and it fails the same way N3 does: it asserts what the record does not say. The price is real — copy metered by the token, one item at a time, your product records in a third party's hands, and text that changes on every call and is therefore never reviewed once and for all — and what it buys is a shop that does not read like a form. If your catalogue is thirty items a copywriter can write once, no rung on this entry is the answer.
Further reading
- The E2E Dataset: New Challenges For End-to-End Generation — écrire une description à partir d'attributs, posé comme tâche
- Findings of the E2E NLG Challenge — ce que les systèmes évalués font des attributs qu'on leur donne
- Hugging Face — les pipelines de transformers, dont text2text-generation employé par l'extrait N2
- OpenAI — Structured Outputs, sur la réponse JSON que l'extrait N3 doit décoder