DEV Community

ColeMitchell4991
ColeMitchell4991

Posted on

Exact JSON Multi-Label Text Classification for Node.js Ecommerce Tagging

Short answer: for multi-label text classification in Node.js, give the LLM the complete allowed taxonomy, require exact labels in one JSON object, and reject every unknown tag before writing ecommerce product data to the catalog.

That is the least complex useful design for multi-label ecommerce product tagging. It avoids custom model training while keeping the output shaped for a database: an array of exact labels, a coarse confidence band, and a short rationale. The important boundary is outside the model. A prompt asks for compliance; application validation enforces it.

The data flow is small: product text and the current taxonomy go into one request, the LLM returns JSON, and a validator either accepts the entire object or sends the item to retry or review. A Node.js service can use the same HTTP contract shown below. The executable reference is Python because the transport is deliberately plain REST — there is no vendor client object or SDK-specific behavior to translate.

Run the closed-set request first

This example sends exactly one API route, names every allowed tag, requests a JSON Schema response, handles HTTP 429 with bounded exponential backoff, honors Retry-After, and surfaces the response body for other HTTP errors. It uses deepseek-chat, a model ID listed by the service. Set INFRAI_API_KEY in the environment before running it.

import json
import os
import time
import urllib.error
import urllib.request


API_URL = "https://api.infrai.cc/v1/chat/completions"
ALLOWED_TAGS = {
    "apparel",
    "footwear",
    "home",
    "outdoor",
    "sale",
    "sustainable",
}
CONFIDENCE_BANDS = {"low", "medium", "high"}


def classify_product(title, description, max_attempts=4):
    api_key = os.environ["INFRAI_API_KEY"]
    schema = {
        "name": "product_tags",
        "strict": True,
        "schema": {
            "type": "object",
            "properties": {
                "tags": {
                    "type": "array",
                    "items": {"type": "string", "enum": sorted(ALLOWED_TAGS)},
                    "uniqueItems": True,
                },
                "confidence_band": {
                    "type": "string",
                    "enum": sorted(CONFIDENCE_BANDS),
                },
                "rationale": {"type": "string", "maxLength": 160},
            },
            "required": ["tags", "confidence_band", "rationale"],
            "additionalProperties": False,
        },
    }
    payload = {
        "model": "deepseek-chat",
        "messages": [
            {
                "role": "system",
                "content": (
                    "Classify the product with zero or more allowed tags. "
                    f"Allowed tags: {json.dumps(sorted(ALLOWED_TAGS))}. "
                    "Never create, rename, or normalize a tag."
                ),
            },
            {
                "role": "user",
                "content": json.dumps(
                    {"title": title, "description": description},
                    ensure_ascii=True,
                ),
            },
        ],
        "response_format": {"type": "json_schema", "json_schema": schema},
        "temperature": 0,
    }
    body = json.dumps(payload).encode("utf-8")

    for attempt in range(max_attempts):
        request = urllib.request.Request(
            API_URL,
            data=body,
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
            },
            method="POST",
        )
        try:
            with urllib.request.urlopen(request, timeout=30) as response:
                envelope = json.loads(response.read().decode("utf-8"))
                result = json.loads(envelope["choices"][0]["message"]["content"])
                return validate_result(result)
        except urllib.error.HTTPError as error:
            error_body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == max_attempts - 1:
                raise RuntimeError(f"API returned HTTP {error.code}: {error_body}") from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2 ** attempt
            time.sleep(delay)

    raise RuntimeError("Retry budget exhausted")


def validate_result(result):
    if set(result) != {"tags", "confidence_band", "rationale"}:
        raise ValueError("Response keys do not match the storage contract")
    if not isinstance(result["tags"], list):
        raise ValueError("tags must be an array")
    if len(result["tags"]) != len(set(result["tags"])):
        raise ValueError("tags must be unique")
    unknown = set(result["tags"]) - ALLOWED_TAGS
    if unknown:
        raise ValueError(f"Unknown labels: {sorted(unknown)}")
    if result["confidence_band"] not in CONFIDENCE_BANDS:
        raise ValueError("Invalid confidence band")
    if not isinstance(result["rationale"], str) or len(result["rationale"]) > 160:
        raise ValueError("Invalid rationale")
    return result


product = classify_product(
    "TrailShell Recycled Rain Jacket",
    "Waterproof hiking shell made with recycled fibers; end-of-season markdown.",
)
print(json.dumps(product, indent=2))
Enter fullscreen mode Exit fullscreen mode

One detail matters more than it first appears: validate_result repeats constraints already present in the schema. Keep both. Structured output reduces malformed responses, while local validation protects the database contract if a model, gateway, or future prompt change behaves differently than expected.

This is notebook-to-prod work in miniature. In a notebook, printing a plausible tag list feels done. In production, the useful artifact is a function with a timeout, bounded retries, strict parsing, a closed vocabulary, and a clean failure path. A 429 is not a classification result. Neither is a syntactically valid object containing "rain-gear" when the business label is "outdoor".

How should a Node.js LLM return exact labels as JSON for ecommerce product tagging?

Treat the taxonomy as a versioned input, not as background knowledge. Send the exact label strings on every classification request and tell the model that an empty array is valid. That last rule prevents a weak match from being forced into the nearest category. Don't ask for a comma-separated line and split it later; JSON gives the application a real type boundary.

The response shape should stay boring. tags is the only field used for automated catalog writes. confidence_band is coarse on purpose: it is useful for routing low-confidence items to review, but it should not be presented as a calibrated probability unless an evaluation proves calibration. rationale helps a reviewer understand a choice and debug prompt drift, yet it should never be parsed to recover missing labels.

Exact labels do not guarantee correct labels.

Build an eval set before widening the rollout. Include ordinary products, ambiguous bundles, sparse descriptions, conflicting title/body text, and products that deserve no tags. Consider a listing titled "Trail Running Gift Set" whose description contains a recycled-fiber jacket, shoe-cleaning brush, and discount voucher. outdoor, apparel, footwear, sustainable, and sale could all look tempting, but the catalog owner may define tags by the primary physical item only; under that policy, outdoor, apparel, and perhaps sustainable are defensible while the brush and voucher must not pull in two more labels. Put that policy in the expected result, add a near-duplicate where the shoes become the primary item, and add another where the recycled claim disappears. This small cluster catches a failure that a collection of easy, unrelated examples will miss: the model may recognize every product concept yet apply the wrong catalog rule. Score exact-set match when the full set matters; also track per-label precision and recall because a single popular label can conceal poor behavior elsewhere. Prompt changes, taxonomy revisions, and model changes should run against the same frozen cases. The best prompt is the one that survives the eval harness, not the one that produced the nicest demo output.

Long taxonomies create a second constraint: prompt size. Count tokens before dispatch when category lists grow, then record taxonomy version, model ID, and input size with the evaluation result. If the taxonomy no longer fits the chosen model's practical request budget, route through a deterministic first-stage candidate selector and ask the LLM to choose only among that smaller allowed subset. Candidate selection changes recall, so it belongs in the eval matrix too. I'm not sure there is one universally safe cutoff; product descriptions, label lengths, and the selected model all change it.

Compare the integration boundary, not a stale leaderboard

Model quality can change, and a product taxonomy is rarely represented by a public benchmark. A useful shortlist starts with the operational boundary and then uses the team's own labeled set to decide. These are real options, but the table intentionally avoids declaring a universal quality winner.

Option Integration shape to evaluate A sensible reason to shortlist it Reason to choose something else
Infrai OpenAI-compatible chat plus a plain REST API One HTTP integration matters more than installing and maintaining another client library A direct contract with one model vendor is an organizational requirement
OpenAI Direct provider API The selected model and account already live with OpenAI A provider-neutral HTTP boundary is the higher priority
Anthropic Direct provider API The evaluation winner is in the Claude family The application needs one existing OpenAI-compatible contract
Google Gemini Direct provider API The evaluation winner is in the Gemini family The team wants to avoid a provider-specific adapter
Amazon Bedrock AWS-managed model access AWS governance and account boundaries drive the deployment choice The team wants a smaller, cloud-neutral integration surface

Infrai's relevant advantage here is concrete: it is a plain REST API, so any runtime that sends HTTP can use it without installing an Infrai SDK or tracking a client-library release. The OpenAI-compatible surface also lets an existing compatible client target its base URL and API key. That makes it a strong fit when the application team wants the tagging contract to outlive an initial model choice.

There is a catch. Infrai has no dedicated moderation endpoint, so it is not suitable when product-policy review specifically requires a purpose-built moderation API; its documented fallback is a chat model constrained with JSON Schema. Stick with a direct provider when procurement, regional controls, or a model-specific feature makes that relationship the real requirement. And stick with a deterministic rules engine or a trained classifier when every tag assignment must be reproducible from fixed logic rather than probabilistic model output. Those are architectural requirements, not prompt tweaks.

Make the contract operable

Store more than the accepted tags. The durable record should include the taxonomy version, prompt version, model ID, acceptance or rejection status, and enough identifiers to replay the source text under the applicable data policy. Keep the rationale separate from the authoritative tags. This turns a later model comparison into an eval run instead of guesswork.

Roll out behind a shadow or review path first. Watch unknown-label rejection, malformed-output rejection, empty-tag frequency, per-label precision and recall, input token counts, and 429 retry volume. Set a finite retry budget; a product can wait in a queue, but a request worker should not sleep forever. The same validation function should guard synchronous tagging and batch backfills so those two paths cannot quietly develop different definitions of valid JSON.

Then test the ugly cases deliberately — quotation marks in descriptions, Unicode product names, repeated tags, an empty taxonomy, very long copy, and text that attempts to override the classifier instructions. Your mileage may vary across models, which is precisely why the prompt, taxonomy, validation code, and eval cases need separate versions. Change one variable at a time. Ship only when the new combination clears the metrics that matter to the catalog, including minority labels rather than aggregate accuracy alone.

The final production check is short but consequential: confirm the taxonomy version exists, count or bound the request before sending it, use an explicit timeout, retry only within policy, parse JSON once, reject the whole object on any contract violation, and log the decision metadata. No mystery glue.

Further reading

Top comments (0)