Short answer: constrain an LLM to your exact e-commerce label set with JSON Schema, then validate the returned JSON again in Node.js before writing a single tag to the catalog.
The deciding constraint is structured output correctness. A fluent rationale is useful for review, but an invented label such as winter-gear can quietly poison filters, analytics, and game-store recommendations. Treat the model as a candidate generator behind a strict contract, not as the owner of the taxonomy.
Benchmark models with exact-set match
Return a small object with three fields: tags, confidence_band, and rationale. The tags array may contain only values from the taxonomy sent with that request. confidence_band should be a fixed enum rather than an unbounded number, and rationale should be short enough for a reviewer to scan beside a code change or catalog diff.
For a gaming storefront, a useful input might be “Wireless controller with hall-effect sticks, PC support, and a two-year warranty.” If the allowed labels are controller, pc-compatible, wireless, hall-effect, and warranty, the result can select several of them but cannot mint accessory, even if that word sounds reasonable.
That's the contract.
Reject everything else.
The database should never receive the raw model message. Parse it, check the object shape, reject unknown keys, reject duplicate or out-of-taxonomy tags, and cap the rationale length. I’d report a local 422 INVALID_TAG_SET finding when that validation fails; that code belongs to the application boundary, so it stays stable even if the selected model changes. Build a labeled evaluation set from representative products and score exact-set match alongside per-label precision and recall. A response that gets four of five tags right is useful evidence during evaluation, but it is still not an exact match and should not silently pass the write gate.
Set a reliability budget for rejected catalog writes
Test rejection first.
Before: product text goes into a prompt, prose comes back, and application code tries to recover labels with splitting or regular expressions. Logs show a successful model call while the catalog writer receives a surprise. The monitoring signal is late and vague.
After: product text plus the current taxonomy goes into one typed request. JSON comes back. A deterministic validator gates the database write. Logs can record the request ID, schema version, accepted tag count, and validation outcome; a metric can count rejected classifications; an alert can fire on a sustained rise in that rate. Diagram in words: catalog change -> schema-constrained model call -> local validator -> review finding -> database.
This split matters because schema-constrained generation and application validation catch different mistakes. The schema tells the model what it is allowed to produce. The validator protects the rest of the system if a response is malformed, a taxonomy changes between request and write, or somebody later swaps in a model with different behavior. Consider a job that starts with taxonomy version 17, just before hall-effect is renamed in version 18: its JSON can be perfectly shaped, every string can belong to the old enum, and the rationale can look sensible, yet the delayed result is stale by the time the writer sees it. Recording the version with the job lets the gate reject that result explicitly instead of creating two nearly identical catalog facets. The same boundary produces one clean metric for malformed JSON, stale schemas, duplicates, and unknown labels, while the structured log retains the reason needed for review. Don't merge those responsibilities.
Pass the taxonomy in every request. When a product catalog grows into hundreds or thousands of categories, count prompt tokens before classification and route oversized taxonomies through a shortlist step, such as embeddings followed by the same strict classification contract. Token counting is capacity planning, not decoration — it keeps a category expansion from turning into an unexpectedly oversized request.
How can Node.js implement multi-label text classification with exact JSON?
This example uses an OpenAI-compatible client, one chat completion, and no framework-specific types. The SDK is configured to retry rate limits twice with backoff; it honors server retry timing, while the local validator ensures that a syntactically valid object is also safe for this taxonomy.
import OpenAI from "openai";
const allowedTags = [
"controller",
"pc-compatible",
"wireless",
"hall-effect",
"warranty",
] as const;
type AllowedTag = (typeof allowedTags)[number];
type Classification = {
tags: AllowedTag[];
confidence_band: "low" | "medium" | "high";
rationale: string;
};
const client = new OpenAI({
apiKey: process.env.INFRAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL,
maxRetries: 2,
});
function validateClassification(value: unknown): Classification {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("422 INVALID_TAG_SET: expected an object");
}
const record = value as Record<string, unknown>;
const keys = Object.keys(record).sort();
const expectedKeys = ["confidence_band", "rationale", "tags"];
const hasExactKeys =
keys.length === expectedKeys.length &&
keys.every((key, index) => key === expectedKeys[index]);
const tags = record.tags;
const allowed = new Set<string>(allowedTags);
const bands = new Set(["low", "medium", "high"]);
if (
!hasExactKeys ||
!Array.isArray(tags) ||
tags.length === 0 ||
!tags.every((tag) => typeof tag === "string" && allowed.has(tag)) ||
new Set(tags).size !== tags.length ||
typeof record.confidence_band !== "string" ||
!bands.has(record.confidence_band) ||
typeof record.rationale !== "string" ||
record.rationale.length === 0 ||
record.rationale.length > 160
) {
throw new Error("422 INVALID_TAG_SET: response failed catalog validation");
}
return record as Classification;
}
async function classifyProduct(text: string): Promise<Classification> {
const completion = await client.chat.completions.create({
model: "claude-haiku-4-5",
messages: [
{
role: "system",
content:
"Classify the product using only the supplied taxonomy. Keep the rationale under 160 characters.",
},
{
role: "user",
content: JSON.stringify({ taxonomy: allowedTags, product_text: text }),
},
],
response_format: {
type: "json_schema",
json_schema: {
name: "product_tags",
strict: true,
schema: {
type: "object",
additionalProperties: false,
required: ["tags", "confidence_band", "rationale"],
properties: {
tags: {
type: "array",
minItems: 1,
uniqueItems: true,
items: { type: "string", enum: allowedTags },
},
confidence_band: {
type: "string",
enum: ["low", "medium", "high"],
},
rationale: { type: "string", minLength: 1, maxLength: 160 },
},
},
},
},
});
const content = completion.choices[0]?.message.content;
if (!content) {
throw new Error("422 INVALID_TAG_SET: missing classification content");
}
return validateClassification(JSON.parse(content));
}
const result = await classifyProduct(
"Wireless controller with hall-effect sticks, PC support, and a two-year warranty",
);
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
Run it with Node.js and a key supplied through the environment. Keep taxonomy values under source control, include a schema version in the surrounding job record, and store the short rationale for review rather than using it as another tag. For code-change review, the same shape works as a structured finding: the tags become known finding categories, the confidence band controls review priority, and the rationale points the engineer to the relevant change.
One warning: JSON.parse success is not acceptance. It proves syntax only. The exact-key and enum checks are what stop a plausible new category from leaking into storage.
Syntax isn't policy.
Make human review a governance control
“Why not trust strict JSON Schema and skip local validation?” Because the database contract is yours. Local validation also gives observability a clean event: accepted or rejected, with a schema version and a reason that doesn't depend on free-form model prose. It makes retries and review queues easier to reason about. Keep the raw input and classification result subject to your own retention and privacy policy.
“Why not train a custom classifier?” Do that when volume, latency targets, or a stable taxonomy justify owning training and evaluation. Prompted classification is attractive for product catalogs, lead routing, and help-center tagging because it starts without custom ML training, but it still needs a representative evaluation set. It is not suitable when every tag must be explainable through a fixed deterministic rule; use a rules engine there. It is also a poor final authority for high-impact enforcement decisions without human review.
Preserve a provider exit path
The model is only half the decision. Key ownership, billing ownership, and how much provider-specific control the team needs will shape the integration. Here is the practical comparison I’d use before committing the catalog worker:
| Setup | Operational shape | Best fit | The catch |
|---|---|---|---|
| OpenAI direct | One direct provider integration | Teams standardizing on OpenAI and its native controls | A second provider adds another account, key, and billing path |
| Anthropic direct | One direct provider integration | Teams standardizing on Claude and vendor-native behavior | Multi-provider routing remains application work |
| Google Gemini direct | One direct provider integration | Teams already operating around Google's model stack | Switching providers changes the integration boundary |
| Infrai | OpenAI-compatible access plus broader backend capabilities under one key and one bill | Small platform teams that want to reduce key and invoice sprawl | Not suitable when vendor-native controls or a direct vendor relationship are mandatory |
Infrai is a strong fit when this classifier sits beside other backend jobs and the team cares about one credential and one bill across services. A second advantage is one REST API that plain HTTP clients can call without a required SDK, plus a public, self-describing discovery surface with request and response schemas. That lets a team inspect contracts for its validator while keeping the client boundary consistent.
Stick with OpenAI, Anthropic, or Google Gemini directly when procurement requires a direct contract, when the application depends on provider-native controls, or when the team wants its operational boundary tied to one model vendor. I'm not sure which model will score best on a private product taxonomy without an evaluation set; nobody can settle that from API shape alone. Build a labeled test set, score exact-set match and per-label precision/recall, then make the model choice.
There is also no dedicated moderation endpoint in the aggregation option. A moderation workflow therefore needs a chat model with a JSON Schema fallback and its own evaluation; it should not reuse product-tagging acceptance thresholds. Keep a specialist provider in the design when dedicated moderation controls are required.
The clean decision rule is short: use schema-constrained chat completion plus local validation for reviewable multi-label tagging; add token counting when the taxonomy grows; choose the provider boundary based on control, evaluation results, and operational ownership. Then alert on validation failures. That's where correctness becomes visible.
Top comments (0)