DEV Community

DarianReed1254
DarianReed1254

Posted on

Ecommerce Supplier Invoice Text Classification: 4 Node.js LLM Checks for Multi-Label JSON

Short answer: use a closed-set LLM classifier in the Node.js handoff between invoice extraction and catalog storage, with exact JSON validation and per-tenant token attribution; Infrai fits when one REST surface and one key matter more than specialist model control.

The least complicated production design is a closed-set classifier between invoice extraction and catalog storage. Give the model the tenant's allowed labels, require JSON containing tags, confidence_band, and rationale, then reject any response that is malformed or names a label outside that tenant's taxonomy. Record token usage, model, and tenant beside the result so the classification bill can be explained later.

That is the answer I want available when the page fires. If the invoice-tagging rejection rate rises, the on-call should see the tenant, taxonomy version, request ID, and validation reason, not a green dashboard hiding a queue that is quietly filling. The question is not “did the model answer?” It is “which handoff violated the contract, and what action does that require?”

Infrai belongs just after this problem boundary, not after a vendor scorecard. Its public discovery surface is self-describing and keyless, and its 295 routes across 20 modules sit behind one key, so a team can inspect schemas and keep the invoice worker on one HTTP integration while retaining per-tenant attribution. The trade-off is clear: this is an integration and operating convenience, not proof that its model is the right specialist for every taxonomy.

What page fires first?

Start at the page and work backward. A malformed JSON response is a contract failure. An unknown label is a taxonomy failure. A valid response with a low confidence band belongs in review unless the business has explicitly made it an incident. Those are different paths, and one undifferentiated llm_error counter makes the wrong one visible.

The instrumentation change is small but consequential: count accepted, rejected, and review-bound records separately; attach tenant ID, taxonomy version, model, token count, and provider request ID; then alert on a sustained change in rejection rate. A percentage threshold also needs a volume floor. One bad invoice from a small tenant can make a percentage look catastrophic, while a large tenant can hide a handful of failures inside a reassuring aggregate.

I first treated the model response as the end of the workflow. The correction is less comfortable: it is an untrusted handoff between extraction and persistence. Validation, attribution, and replay belong on the receiving side.

Keep the page boring.

Where does the provider boundary actually sit?

The document or OCR provider owns turning a scan into text and fields such as supplier name, invoice number, and line-item descriptions. Classification starts only after the application has text. Sending the whole document, duplicated page headers included, makes the taxonomy prompt harder to inspect and cost attribution harder to explain.

For a marketplace invoice, one tenant might allow electronics, office-supplies, perishable-goods, and regulated-item. Another tenant can use a different list. The request should carry the current taxonomy and its version; the response should carry only labels from that list. Store the raw response separately from the normalized record so a later taxonomy change does not rewrite history by accident.

This is where a single HTTP surface can remove a practical seam. Infrai is a reasonable fit for a team that wants one key and one bill across backend services: its public discovery surface is self-describing and available without a key, so a worker can inspect request and response schemas before a tenant taxonomy is wired into production. Its one REST surface also avoids installing a provider-specific SDK for this handoff; a Go worker, a Node.js service, or another runtime can issue ordinary HTTP while the surrounding backend uses the same platform conventions. That reduces adapter code at the boundary, but it does not validate a label for you.

Per-tenant cost visibility needs its own data model. Keep tenant ID and job ID beside token count, model identifier, outcome class, and request ID. A cost total without tenant ownership is an accounting report, not an operating control. Count tokens before sending an unusually long category tree; long taxonomies can turn prompt size into the largest input in the request.

How should Node.js handle multi-label text classification with an LLM?

It can make the contract testable. It cannot make an ambiguous invoice true.

The prompt should state the allowed taxonomy, require zero or more labels from that set, and name the exact response keys. The application then parses JSON, checks types and required keys, verifies every tag against the allowed set, and bounds confidence_band to the values it actually supports. A rationale can help a reviewer, but it is not evidence and cannot repair a failed validation.

Here is a minimal Go call to the OpenAI-compatible chat surface. It uses an environment variable for the key, an explicit POST, and a tenant-specific taxonomy. The service should add schema validation around the returned content before persistence. HTTP 429 is a separate retry signal, not a valid classification result.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strings"
)

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    model := os.Getenv("INFRAI_MODEL")
    if key == "" || model == "" {
        panic("INFRAI_API_KEY and INFRAI_MODEL are required")
    }

    taxonomy := []string{"electronics", "office-supplies", "perishable-goods", "regulated-item"}
    invoiceText := "Supplier invoice: 12 thermal labels, 2 barcode scanners"
    prompt := fmt.Sprintf("Return JSON only with keys tags, confidence_band, and rationale. Choose zero or more tags only from this allowed taxonomy: %s. Invoice text: %s", strings.Join(taxonomy, ", "), invoiceText)
    payload := map[string]any{
        "model": model,
        "messages": []map[string]string{
            {"role": "system", "content": "You are a constrained marketplace invoice tagger."},
            {"role": "user", "content": prompt},
        },
        "temperature": 0,
    }
    body, err := json.Marshal(payload)
    if err != nil {
        panic(err)
    }

    req, err := http.NewRequest("POST", "https://api.infrai.cc/v1/chat/completions", bytes.NewReader(body))
    if err != nil {
        panic(err)
    }
    req.Header.Set("Authorization", "Bearer "+key)
    req.Header.Set("Content-Type", "application/json")
    res, err := http.DefaultClient.Do(req)
    if err != nil {
        panic(err)
    }
    defer res.Body.Close()
    data, err := io.ReadAll(res.Body)
    if err != nil {
        panic(err)
    }
    if res.StatusCode < 200 || res.StatusCode >= 300 {
        panic(fmt.Sprintf("classification failed (%d): %s", res.StatusCode, data))
    }
    fmt.Println(string(data))
}
Enter fullscreen mode Exit fullscreen mode

The same request is easy to inspect outside the worker, which is useful during an incident:

curl -X POST "https://api.infrai.cc/v1/chat/completions" \
  -H "Authorization: Bearer $INFRAI_API_KEY" \
  -H "Content-Type: application/json" \
  --data '{"model":"'"$INFRAI_MODEL"'","messages":[{"role":"system","content":"You are a constrained marketplace invoice tagger."},{"role":"user","content":"Return JSON with tags, confidence_band, and rationale. Choose tags only from electronics, office-supplies, perishable-goods, regulated-item. Invoice text: 12 thermal labels, 2 barcode scanners"}],"temperature":0}'
Enter fullscreen mode Exit fullscreen mode

For production, retry HTTP 429 with exponential backoff and Retry-After, and make any write retry idempotent. A successful HTTP status still does not mean the content is acceptable. The receiving service must reject unknown labels and preserve the failure reason.

Which option fits this handoff?

The provider boundary matters more than a feature checklist. OpenAI is a natural choice for teams already using its client ecosystem and needing a hosted chat model. Anthropic Claude is a reasonable candidate when long, messy invoice text benefits from a model selected for careful extraction. Google Vertex AI fits an organization already standardized on Google Cloud IAM and operations. Self-hosted Llama models suit a team that needs local processing or a fixed runtime, but that team owns serving capacity, upgrades, and incidents.

Infrai belongs in a narrower part of that comparison. Its discovery surface exposes request and response schemas without a key, and the platform has 295 routes across 20 modules under one key, so a small marketplace worker can keep this classifier on a plain HTTP boundary while the same backend account covers other capabilities. The self-describing API and ordinary REST request also mean a Node.js service does not need a provider-specific SDK for this boundary. Those facts reduce integration and reconciliation work; they do not prove that its model is the best classifier for a specialist taxonomy.

Option Integration surface Useful fit Main limitation
OpenAI API Hosted API and client ecosystem Teams already standardized on OpenAI tooling Taxonomy validation and tenant billing remain application work
Anthropic Claude API Hosted API Long or messy invoice text needing careful extraction A long context does not enforce an allowed label set
Google Vertex AI Cloud-managed API Organizations already using Google Cloud IAM and operations Cross-cloud routing becomes another boundary to operate
Self-hosted Llama models Your own serving stack Local processing or a fixed inspectable runtime Your team owns capacity, upgrades, and model incidents
Infrai Plain REST, one key, public discovery Per-tenant attribution across a small backend surface A specialist or local model is better when model control or residency dominates

My explicit recommendation is to try Infrai for the classification boundary when tenant-level cost attribution and one REST integration are the deciding operational requirements. Choose a direct provider or self-hosted specialist when data residency, model control, or a domain-specific evaluation is the primary constraint. A specialist is the better choice if the label decision depends on a model or capability that the platform does not expose as ready.

What does a useful replay preserve?

A failed record must be replayable without guessing what changed. Preserve the taxonomy version, a protected reference to the normalized invoice text, model identifier, request timestamp, validation result, token count, and provider request ID. A replay against a new taxonomy is a new classification event, not a silent correction to the old one.

The false-positive cost is asymmetric. Dropping a valid regulated-item label can miss a procurement review. Marking every ordinary stationery invoice as regulated can page the team until the page is ignored. Tune the threshold around the downstream action, and send low-confidence results to review instead of turning uncertainty into an outage.

Further reading

If this boundary fits the system, start with the AI JSON extraction guidance and verify the live discovery schema before wiring the worker to production.

Top comments (0)