Skip to content
Fondazione ITS INCOM

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:

  1. the model writes a description and three highlights for each row of the catalog, as JSON;
  2. your code checks every number in the text against the row, and looks for risky claims that the row does not make;
  3. 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("&", "&amp;").replaceAll("<", "&lt;");
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.

What they did not catch

Read the pages, and you find more that no rule caught:

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:

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

Exercise

All examples