DEV Community

JamesAnderson121
JamesAnderson121

Posted on

Stable JSON for LLM Multi-Label Catalog Tagging from Node.js

TL;DR

For ecommerce product tagging, make the LLM return label IDs from a closed taxonomy, then let ordinary application code validate the JSON before anything reaches the catalog. A Node.js application can own transport and persistence while a small Python classifier owns prompt construction, validation, and evaluation. The important boundary isn't the language boundary. It's the point where probabilistic output becomes a versioned, testable decision.

Don't parse prose.

The least complex design is one request, one JSON object, and one of two outcomes: an accepted set of exact labels or a typed rejection. Keep the raw model response out of the write path. This gives a notebook experiment somewhere honest to grow without making a prompt responsible for database integrity.

In plain language, the flow is: normalize the product fields, present a finite set of legal labels, receive a candidate JSON value, validate its shape and membership, score it in an eval harness, and only then hand the accepted decision back to the Node.js catalog worker. Transport retries and catalog writes stay outside the classifier. That separation is small, but it prevents several failures from collapsing into a misleading success: true.

Build the executable contract first

The following Python file is intentionally model-agnostic. The model_call function is the only adapter point, so a notebook can use a deterministic fake while production can supply an HTTP client. More important, the validator doesn't care how the candidate was generated.

import hashlib
import json
from dataclasses import dataclass
from typing import Callable, Final


TAXONOMY_VERSION: Final = "catalog-v7"
LABELS: Final = {
    "audience.kids",
    "material.recycled",
    "use.hiking",
    "weather.waterproof",
}


@dataclass(frozen=True)
class ProductText:
    product_id: str
    title: str
    description: str


@dataclass(frozen=True)
class TagDecision:
    product_id: str
    taxonomy_version: str
    labels: tuple[str, ...]
    decision_key: str


class ContractError(ValueError):
    pass


def normalize(value: str) -> str:
    return " ".join(value.split())


def build_model_input(product: ProductText) -> dict:
    return {
        "task": "Return exactly one JSON object with a labels array.",
        "rules": [
            "Use only values from allowed_labels.",
            "Do not infer a label when the product text lacks evidence.",
            "Return no keys other than labels.",
        ],
        "taxonomy_version": TAXONOMY_VERSION,
        "allowed_labels": sorted(LABELS),
        "product": {
            "title": normalize(product.title),
            "description": normalize(product.description),
        },
    }


def validate_candidate(product: ProductText, raw: str) -> TagDecision:
    try:
        candidate = json.loads(raw)
    except json.JSONDecodeError as error:
        raise ContractError("INVALID_JSON") from error

    if not isinstance(candidate, dict) or set(candidate) != {"labels"}:
        raise ContractError("INVALID_OBJECT_SHAPE")

    labels = candidate["labels"]
    if not isinstance(labels, list):
        raise ContractError("LABELS_NOT_ARRAY")
    if any(not isinstance(label, str) for label in labels):
        raise ContractError("LABEL_NOT_STRING")
    if len(labels) != len(set(labels)):
        raise ContractError("DUPLICATE_LABEL")

    unknown = set(labels) - LABELS
    if unknown:
        raise ContractError("UNKNOWN_LABEL")

    ordered = tuple(sorted(labels))
    key_source = json.dumps(
        [product.product_id, TAXONOMY_VERSION, ordered],
        separators=(",", ":"),
    )
    decision_key = hashlib.sha256(key_source.encode()).hexdigest()
    return TagDecision(
        product_id=product.product_id,
        taxonomy_version=TAXONOMY_VERSION,
        labels=ordered,
        decision_key=decision_key,
    )


def classify(
    product: ProductText,
    model_call: Callable[[dict], str],
) -> TagDecision:
    raw = model_call(build_model_input(product))
    return validate_candidate(product, raw)


def fake_model(_: dict) -> str:
    return json.dumps(
        {"labels": ["weather.waterproof", "use.hiking"]}
    )


if __name__ == "__main__":
    item = ProductText(
        product_id="sku-1042",
        title="Waterproof trail shell",
        description="Light outer layer designed for wet hikes.",
    )
    decision = classify(item, fake_model)
    print(json.dumps(decision.__dict__, indent=2))
Enter fullscreen mode Exit fullscreen mode

This example accepts an empty labels array on purpose. β€œNo supported tag” is often a valid classification result, while inventing a plausible tag is not. If the business requires at least one label, that should be a named policy with its own test rather than an assumption hidden in the prompt.

The exact-key check is equally deliberate. A response containing {"labels": [...], "reason": "..."} may be useful during prompt development, but it is a different contract. Accepting extra fields casually makes the interface drift every time someone adjusts a prompt. Keep explanations in a separate debug path, with separate retention rules, if the team genuinely needs them.

The SHA-256 value is a decision key, not proof that the model is deterministic. It identifies the product, taxonomy version, and accepted label set so the catalog worker can recognize the same accepted decision. If prompt or model configuration must distinguish decisions in your system, include their version identifiers in the key source too.

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

Return an object shaped like {"labels": ["use.hiking"]} over the service boundary, with label values copied exactly from a versioned allowlist. Node.js should treat every other shape as rejected input. It shouldn't split commas, extract a JSON-looking substring from prose, lowercase unknown values, or silently map a near-match to a legal tag.

Why be so strict? JSON validity answers only a syntax question. {"labels": ["outdoor"]} is valid JSON and still invalid for the taxonomy above. Conversely, a useful semantic guess wrapped in commentary isn't valid input to the application contract. Syntax, object shape, and label membership are three separate checks, and the classifier needs all three.

There is a tempting shortcut here: ask for a confidence score and persist labels above a threshold. The catch is that a generated number isn't automatically calibrated. A threshold such as 0.8 has no operational meaning until held-out examples show what precision and recall it produces for this taxonomy and model configuration. For many catalog workflows, explicit abstention plus label-level evaluation is easier to reason about than a decorative decimal.

The Node.js caller needs a compact response envelope around the accepted decision. It can add its request ID and transport status without changing classifier semantics. On rejection, return a stable code such as UNKNOWN_LABEL or INVALID_OBJECT_SHAPE; don't return a partly repaired label set. A queue worker can then decide whether a contract rejection goes to review while a timeout follows the transport retry policy.

One detail deserves a longer look β€” images. Ecommerce records often mix text and media, but an image-processing step and a text classifier are different components. A Node.js image library can resize or normalize assets before a separate vision-capable path, yet those pixels shouldn't be smuggled into a text-only contract as if they were product prose. Keep provenance on every input field. When an image-derived attribute later becomes classifier input, label it as image-derived and evaluate that combined pipeline independently; the sharp documentation is a useful reference for the image-processing boundary, not evidence that image processing performs classification.

Evaluate the set, not the prettiness of the JSON

A runnable demo proves that the plumbing works. It says almost nothing about whether the tags are good.

Start the eval set in the notebook where taxonomy mistakes are cheap to inspect. Each example needs normalized product text, expected label IDs, the taxonomy version, and a short annotation for genuine ambiguity. Include sparse titles, conflicting description fields, products with several valid tags, products with no valid tags, and labels that appear rarely. Freeze a test split before prompt tuning; otherwise each prompt edit can quietly optimize for examples already seen.

For multi-label classification, exact-set match is the cleanest first metric: the predicted set must equal the expected set. It is harsh, which is useful, but it can't explain the miss. Pair it with per-label precision and recall, micro and macro aggregates, invalid-contract rate, abstention rate, latency, and tokens per accepted item. The last denominator matters to a prompt-cost-aware team because malformed attempts and retries consume work without yielding a usable decision.

I use a tiny scoring function like this before adding a larger evaluation framework:

from dataclasses import dataclass


@dataclass(frozen=True)
class EvalResult:
    exact_matches: int
    total: int
    false_positives: int
    false_negatives: int


def score_label_sets(
    expected: list[set[str]],
    predicted: list[set[str]],
) -> EvalResult:
    if len(expected) != len(predicted):
        raise ValueError("EVAL_LENGTH_MISMATCH")

    exact_matches = sum(
        expected_set == predicted_set
        for expected_set, predicted_set in zip(expected, predicted)
    )
    false_positives = sum(
        len(predicted_set - expected_set)
        for expected_set, predicted_set in zip(expected, predicted)
    )
    false_negatives = sum(
        len(expected_set - predicted_set)
        for expected_set, predicted_set in zip(expected, predicted)
    )
    return EvalResult(
        exact_matches=exact_matches,
        total=len(expected),
        false_positives=false_positives,
        false_negatives=false_negatives,
    )


fixture = score_label_sets(
    expected=[{"use.hiking"}, {"material.recycled"}, set()],
    predicted=[{"use.hiking"}, set(), {"audience.kids"}],
)
print(fixture)
Enter fullscreen mode Exit fullscreen mode

That three-row fixture has one exact match, one false negative, and one false positive. The numbers aren't a benchmark; they are a hand-checkable test of metric behavior. Add tests for duplicate labels, unknown labels, extra object keys, invalid JSON, an empty set, and Unicode product text before swapping the fake adapter for a real model call.

Large taxonomies introduce a separate decision. Sending every label definition on every request increases prompt size, while retrieving a candidate subset can omit the correct label before classification even starts. Embeddings can support candidate retrieval, as described in the OpenAI embeddings guide, but retrieval adds an index, a version relationship, and a candidate-recall metric. It is not suitable when the taxonomy is small enough to pass in full or when missing a legal candidate is unacceptable. Stick with the full allowlist in those cases. If retrieval is justified, measure candidate recall separately from final classification accuracy so the team knows which stage lost the answer.

I'm not sure there is a universal taxonomy size where retrieval becomes the right choice; label-description length, input limits, latency goals, and acceptable miss rate all move that boundary. A measured crossover in your own eval harness would resolve it. Your mileage may vary.

Move from notebook to production without merging failure domains

Production needs explicit states: received, normalized, classified, validated, write-pending, confirmed, or rejected. The classifier may produce only validated or rejected. The catalog worker owns the later transition, applies the decision under an idempotency rule, and confirms the stored state before declaring the job complete. This is intentionally dull. Good.

Retries follow the same ownership. A transport timeout may be retried under a bounded policy. A deterministic UNKNOWN_LABEL rejection should not be retried unchanged, because the same taxonomy and candidate will fail again. A catalog mutation should be replayed only with an idempotency mechanism understood by that catalog. Combining all three into a generic retry loop risks repeated writes and hides the actual failure rate.

Observability should preserve enough context for evaluation without turning every product record into permanent prompt logging. Record a request ID, input hash, taxonomy version, prompt version, model configuration identifier, validation code, accepted labels, latency, token usage when available, and state transition times. Decide separately whether raw titles, descriptions, and candidate output may be retained. Supplier feeds can contain data with different access expectations from ordinary application logs.

Deployment begins with shadow decisions that cannot write tags. Compare those decisions with a frozen labeled set and a sample of current traffic, then canary the write path for a bounded segment. Watch invalid-contract rate, exact-set quality on reviewed samples, queue age, retry count, latency, and tokens per accepted item. Rollback means restoring the previous versioned classifier contract while leaving each stored decision traceable to the taxonomy and prompt that produced it.

The operational checklist is prose because the steps are coupled. Before release, run contract tests against malformed and semantically invalid JSON, run the frozen eval set, verify taxonomy-version compatibility on both sides of the Node.js/Python boundary, and exercise the write confirmation path without model inference. During rollout, keep writes disabled until shadow results meet the team's predeclared thresholds. After enabling writes, inspect rejected examples and label distribution shifts before spending time trimming prompts. Correct tags come first; lower token use is an optimization constrained by the evals.

The limitation of this split service is operational overhead. A separate Python process adds deployment, tracing, timeout, and version-coordination work. It is not suitable when the Node.js codebase already has an equally testable classification and evaluation stack, or when the volume doesn't justify another service. In that case, keep the same closed-set contract and state machine inside one application. The architecture matters more than the process boundary.

References

Further reading

Top comments (0)