Examples · Web Development
Product pages from a catalog
A small JSON catalog goes in, and a description with three highlights comes out for every product, as JSON. Your code checks that every number in the text is in the catalog row, and stops claims the catalog does not make; only the pages that pass go on the site.
Intermediate · 35 min
The idea
A shop with a hundred products needs a hundred descriptions. The facts are already in the catalog: price, size, weight, material. A model can turn them into friendly sentences in a few seconds.
On a product page, every sentence is a promise to a customer. A weight that is wrong, a “waterproof” that is not in the data: the customer buys, and sends it back. So:
- the model writes a description and three highlights for each row of the catalog, as JSON;
- your code checks every number in the text against the row, and looks for risky claims that the row does not make;
- only the pages that pass go into
products.html, with the price written by your code, not by the model.
What you need
Node.js 22 or newer, a key and the openai package, as in Your first API call in JavaScript.
The catalog
An outdoor shop in Legnano (the shop and its products are invented). Save it as catalog.json:
[
{
"sku": "BO-ZT28",
"name": "Ticino 28 backpack",
"price_eur": 89.90,
"volume_l": 28,
"weight_g": 950,
"size_cm": "52 × 30 × 20",
"material": "recycled polyester",
"colours": ["slate grey", "forest green"],
"features": ["sleeve for laptops up to 15 inches", "rain cover in the bottom pocket", "chest strap"]
},
{
"sku": "BO-SG750",
"name": "Sorgente bottle",
"price_eur": 24.50,
"capacity_ml": 750,
"weight_g": 380,
"material": "stainless steel, double wall",
"features": ["keeps drinks cold for 24 hours and hot for 12 hours", "hand wash only"]
},
{
"sku": "BO-FA300",
"name": "Faro 300 headlamp",
"price_eur": 34.00,
"light_lumen": 300,
"beam_m": 80,
"battery_mah": 1200,
"weight_g": 78,
"features": ["USB-C charging", "4 hours at full power, 40 hours at low power", "splash-proof (IPX4)"]
},
{
"sku": "BO-MR135",
"name": "Monte Rosa trekking poles (pair)",
"price_eur": 59.00,
"length_cm": "105–135",
"weight_g": 520,
"material": "aluminium, cork grips",
"features": ["flick locks", "sold as a pair"]
},
{
"sku": "BO-GR05",
"name": "Grigna sleeping bag",
"price_eur": 129.00,
"comfort_c": 5,
"limit_c": 0,
"weight_g": 1100,
"packed_cm": "22 × 35",
"length_cm": 210,
"material": "synthetic fill",
"features": ["compression sack included", "two-way zip"]
},
{
"sku": "BO-LM340",
"name": "Lago Maggiore rain jacket",
"price_eur": 119.00,
"weight_g": 340,
"waterproof_mm": 10000,
"sizes": ["S", "M", "L", "XL"],
"features": ["packs into its own pocket", "adjustable hood", "two hand pockets"]
}
]
The program
Save it as pages.mjs, next to the catalog:
import { readFileSync, writeFileSync } from "node:fs";
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.ai.itsincom.org/v1",
apiKey: process.env.API_KEY,
});
const model = process.argv[2] ?? "qwen3.8-27b";
const catalog = JSON.parse(readFileSync("catalog.json", "utf8"));
const schema = {
type: "object",
properties: {
description: { type: "string" },
highlights: { type: "array", items: { type: "string" } },
},
required: ["description", "highlights"],
};
// Claims a shop must not make unless the catalog makes them. Add your own.
const RISKY = ["waterproof", "water-resistant", "windproof", "breathable", "warranty", "guarantee", "lifetime",
"handmade", "made in", "dishwasher", "bpa", "certified", "organic", "eco-friendly", "sustainable", "lightest"];
// Every number in a text, as a number: «1,100» is 1100 and «89.90» is 89.9.
const numbers = (text) => (text.match(/\d+(?:,\d{3})*(?:\.\d+)?/g) ?? []).map((n) => Number(n.replaceAll(",", "")));
const pages = [];
for (const row of catalog) {
const answer = await client.chat.completions.create({
model,
temperature: 0.7,
messages: [
{
role: "system",
content: "You write the product pages of Bottega Olona, an outdoor shop in Legnano. From a row of the catalog, " +
"write in English a description of two or three sentences, and three short highlights. Friendly and " +
"concrete. Use only the facts in the row; write every number exactly as it is in the row.",
},
{ role: "user", content: JSON.stringify(row) },
],
response_format: { type: "json_schema", json_schema: { name: "product_page", schema } },
});
if (answer.choices[0].finish_reason !== "stop") throw new Error(`${row.sku}: the answer was cut off.`);
const page = JSON.parse(answer.choices[0].message.content);
const text = [page.description, ...page.highlights].join("\n");
const data = JSON.stringify(row).toLowerCase();
const known = new Set(numbers(JSON.stringify(row)));
const problems = [
...numbers(text).filter((n) => !known.has(n)).map((n) => `${n} is not in the catalog row`),
...RISKY.filter((word) => text.toLowerCase().includes(word) && !data.includes(word))
.map((word) => `«${word}» is not in the catalog row`),
];
console.log(`\n${row.sku} ${row.name}\n${page.description}`);
for (const highlight of page.highlights) console.log(` - ${highlight}`);
for (const problem of problems) console.log(` CHECK: ${problem}`);
if (problems.length === 0) pages.push({ row, page });
}
// Only the pages that passed go on the site. The price comes from the catalog, not from the model.
const escape = (text) => text.replaceAll("&", "&").replaceAll("<", "<");
const html = pages.map(({ row, page }) => `<article id="${row.sku}">
<h2>${escape(row.name)}</h2>
<p class="price">€ ${row.price_eur.toFixed(2)}</p>
<p>${escape(page.description)}</p>
<ul>${page.highlights.map((h) => `<li>${escape(h)}</li>`).join("")}</ul>
</article>`);
writeFileSync("products.html", html.join("\n") + "\n");
console.log(`\nproducts.html: ${pages.length} of ${catalog.length} products; the others need a person.`);
Run it with node pages.mjs, or node pages.mjs gemma-4-26b. With qwen3.8-27b the six products take about 45 seconds, with gemma-4-26b about 8.
What you should see
A run with qwen3.8-27b:
BO-ZT28 Ticino 28 backpack
The Ticino 28 backpack is a 28 L pack made from recycled polyester, weighing just 950 g and measuring 52 × 30 × 20 cm. Available in slate grey or forest green, it comes with a chest strap, a sleeve for laptops up to 15 inches, and a rain cover stowed in the bottom pocket. At 89.9 €, it's a tidy, eco-friendly companion for your daily trips.
- 28 L of recycled-polyester capacity at only 950 g
- Laptop sleeve up to 15 inches plus a built-in rain cover
- Chest strap for a secure, comfortable fit
CHECK: «eco-friendly» is not in the catalog row
BO-SG750 Sorgente bottle
The Sorgente bottle is a 750 ml, double wall stainless steel bottle weighing just 380 g. It keeps drinks cold for 24 hours and hot for 12 hours, so your coffee or water stays exactly the way you like it from morning to night. Priced at €24.5, it's a sturdy, no-fuss companion for your outdoor days.
- 750 ml of double wall stainless steel in a 380 g body
- Cold for 24 hours, hot for 12 hours
- Hand wash only – easy to care for
…
products.html: 5 of 6 products; the others need a person.
The backpack is made of recycled polyester, and the model made it “eco-friendly”. The catalog does not say that. In the EU, a generic green claim like “eco-friendly” is not allowed unless the seller can show a recognized excellent environmental performance, for example with the EU Ecolabel (Directive (EU) 2024/825, whose rules apply from 27 September 2026). “Made from recycled polyester” is a specific claim, and the catalog supports it. The page stays out of products.html until a person rewrites it.
The pages that pass go into products.html. The bottle, from another run:
<article id="BO-SG750">
<h2>Sorgente bottle</h2>
<p class="price">€ 24.50</p>
<p>The Sorgente bottle is a 750 ml stainless steel, double-wall bottle weighing 380 g, built to keep your drinks cold for 24 hours and hot for 12. It's a simple, sturdy companion for your daily routine or a day out in the fresh air. Just remember: hand wash only.</p>
<ul><li>Keeps drinks cold for 24 hours and hot for 12</li><li>750 ml capacity in just 380 g of double-wall stainless steel</li><li>Hand wash only</li></ul>
</article>
…
What the checks caught
We ran the program four times with qwen3.8-27b and three times with gemma-4-26b: 42 pages.
- Numbers: every number in every text was in its row. The check never fired. The last sentence of the system message, “write every number exactly as it is in the row”, does most of the work; the check makes sure.
- Risky words: one page, the “eco-friendly” backpack above.
What they did not catch
Read the pages, and you find more that no rule caught:
- “Built-in rain cover”: in three of Qwen's four runs. The catalog says the rain cover is in the bottom pocket.
- “Ergonomic cork grips”, from Gemma, in one run of three.
- “Comfort rating: 5”, from Gemma, in two runs of three. The number is right; the “°C” is gone.
- Prices as “89.9 €” and “€24.5”. The model saw
89.9:JSON.parsereads89.90as the number 89.9, andJSON.stringifywrites it that way. That is why the price on the page comes fromtoFixed(2), in your code.
The checks find what a rule can describe. The rest is for a person, who reads every page before it goes online; the checks tell them where to look first.
A looser request
To see what the last sentence of the system message does, we removed it: “Use only the facts in the row; write every number exactly as it is in the row.” Two runs per model:
- Gemma wrote “eco-friendly” for the backpack in both runs (the check stopped it), and fewer numbers: in both runs its sleeping bag had none, only “High thermal comfort” or “great warmth” where the 5 °C should be.
- Qwen wrote “520 g per pole” in both runs. The pair weighs 520 g, so each pole weighs half. The number is in the row: the check passes, and the sentence is wrong.
- Qwen also used the shop's town in the stories: “a morning stroll along Lake Legnano” (there is no lake in Legnano), “your night run through the Legnano hills” (Legnano is in the plain), “Named after the Grigna peaks just above Legnano” (the Grigna is above Lecco, about 50 km away).
- The checks made two mistakes of their own. Qwen wrote “1 200 mAh”, with a space in the thousands, and the program read 1 and 200. And it wrote “just skip the dishwasher”, which is right, because the bottle is hand wash only, and the word list stopped it.
One of Qwen's two runs passed every check, with “520 g each”, “the Legnano hills” and a “waterproof membrane” in it. The request keeps the model close to the data; the checks catch some of what gets away; a person reads the rest.
Good to know
- A list of risky words is never finished. Start with the claims that cost money when they are wrong (waterproof, warranty, dishwasher), and add the words you find in your own reviews: after these runs, “built-in”, “integrated” and “ergonomic”.
- Compare numbers as numbers. “89.90” and “89.9” are the same price, “1,100” and “1100” the same weight. The program removes the commas and calls
Number(), so they are equal; comparing strings would not. - Send the model only what it needs. The price is on the page already, written by your code. Without
price_eurin the request, the model cannot get it wrong. temperature: 0.7gives different words at every run: good for a hundred descriptions that should not all sound the same, and a reason to check every run.- The number check trusts the codes too. Numbers inside SKUs and names (the 5 of
BO-GR05) count as known, so a wrong number that happens to match one of them passes. Collect the numbers from the fields that hold measures and prices only.
Exercise
- Fix the regular expression so that “1 200” is read as 1200. Then make sure “52 × 30 × 20” still gives three numbers.
- Make the number check stricter: for each field, its number must come with its unit:
weight_gwith “g”,comfort_cwith “°C”. Does “Comfort rating: 5” fail now? - Catch “520 g per pole”: when a row says “sold as a pair”, flag the words “per pole” and “each”.
- Ask a second model to review each page: give it the row and the text, and ask for a list of claims that are not in the row, each with the exact words of the text. Check in code that those words are really in the text.
