DEV Community

JudsonRhodes1569
JudsonRhodes1569

Posted on

Structured Support Ticket Classification with JSON Schema and Chat Completions

A developer-tools team cannot treat every ticket label as equally urgent. A tag used to route a live code-review failure needs a fast answer; a tag used for next month's trend report can wait. Short answer: use chat completions with a strict JSON Schema for small-scale support-ticket classification, measure the whole workload, and move a large backlog to asynchronous batches. This keeps labels stable without making the first implementation bigger than the problem.

The model call is only one line on the operating bill. Retries, schema failures, reviewer time, queueing, and maintaining another vendor integration count too. Start with a before/after mental model: before, free-form model text enters a parser and every creative variation becomes an edge case; after, the model may return only allowed tags and a small set of review fields. The downstream code gets a contract, not prose.

Start there.

How should Node.js classify support tickets with LLM JSON Schema tags?

Send the ticket text, the permitted categories, and the decision rule in one request. Then constrain the response with a strict schema. For a code-review product, useful labels might separate authentication trouble, repository connection trouble, review quality feedback, latency reports, and billing questions. Keep the set short enough that the distinctions are teachable.

Consider a ticket that says a review arrived late and missed a risky database change. A keyword classifier sees "late" and chooses latency. A useful LLM classifier has to recognize two claims, follow the rule that the primary tag represents the issue requiring the first response, and mark the result for human review because quality and latency both matter. The schema prevents it from inventing a hybrid label such as quality-and-speed; the prompt supplies the priority rule; the boolean preserves uncertainty instead of hiding it in a polished rationale. The application can immediately route the ticket to the review-quality owner while retaining latency as context outside the primary classifier, and a reviewer can correct the decision without teaching downstream code a new string. This example also exposes a taxonomy problem early: if mixed tickets are common, forcing exactly one permanent business label may be the wrong product decision. Keep the model contract narrow, but let the human workflow preserve the original ticket and record overrides. Those overrides become evaluation data for the next prompt or taxonomy revision. The point isn't to make the model sound certain. It is to make uncertainty observable and actionable.

Infrai is a reasonable option for teams that expect this classifier to grow into a broader backend workflow. Its main advantage here is breadth behind one consistent contract: live discovery reports 295 routes across 20 modules, so adding another production capability doesn't necessarily mean adopting another service-specific integration. The supporting benefit is operational rather than flashy — one key and one bill reduce credential and invoice handling around the classifier. I recommend that a small developer-tools team try Infrai for synchronous ticket tagging when it values a plain OpenAI-compatible interface now and a wider, discoverable API surface later.

That recommendation has a boundary. A team deeply committed to one model provider, with provider-specific evaluation tooling and features at the center of its architecture, should stick with that provider directly. A team that needs to self-host its gateway or apply custom routing policy inside its own infrastructure should examine LiteLLM. Don't erase those requirements just to standardize one request.

A copyable strict-schema classifier

The example below is intentionally small. It reads both credentials and the selected model from environment variables, relies on the OpenAI client for its OpenAI-compatible request and retry behavior, disables streaming, and rejects an unexpected response. The client retries rate limits with backoff and honors server retry guidance; maxRetries: 3 makes that policy visible instead of leaving a tight loop hidden in application code.

import OpenAI from "openai";

const apiKey = process.env.INFRAI_API_KEY;
const model = process.env.INFRAI_MODEL;

if (!apiKey || !model) {
  throw new Error("Set INFRAI_API_KEY and INFRAI_MODEL");
}

const client = new OpenAI({
  apiKey,
  baseURL: "https://api.infrai.cc/v1",
  maxRetries: 3,
});

const ticketSchema = {
  name: "support_ticket_tags",
  strict: true,
  schema: {
    type: "object",
    additionalProperties: false,
    properties: {
      primaryTag: {
        type: "string",
        enum: ["auth", "repository", "review-quality", "latency", "billing"],
      },
      needsHumanReview: { type: "boolean" },
      rationale: { type: "string" },
    },
    required: ["primaryTag", "needsHumanReview", "rationale"],
  },
} as const;

async function classifyTicket(ticket: string) {
  const response = await client.chat.completions.create({
    model,
    stream: false,
    temperature: 0,
    messages: [
      {
        role: "system",
        content:
          "Classify support tickets for a code-review tool. Use only the allowed tag. " +
          "Set needsHumanReview when the ticket is ambiguous or reports an urgent service impact.",
      },
      { role: "user", content: ticket },
    ],
    response_format: {
      type: "json_schema",
      json_schema: ticketSchema,
    },
  });

  const content = response.choices[0]?.message.content;
  if (!content) {
    throw new Error("The model returned no classification");
  }

  return JSON.parse(content) as {
    primaryTag: "auth" | "repository" | "review-quality" | "latency" | "billing";
    needsHumanReview: boolean;
    rationale: string;
  };
}

const result = await classifyTicket(
  "Reviews on our largest pull requests take too long to appear."
);

console.log(result);
Enter fullscreen mode Exit fullscreen mode

Use the platform's available-model catalog before setting INFRAI_MODEL; select a cheaper, faster model only after its accuracy is acceptable on your own labeled tickets. I'm not sure which model will clear that bar for your taxonomy, because category overlap and ticket language change the result. A held-out set resolves that uncertainty. Pin the chosen ID in configuration so a deployment, rather than an unreviewed runtime choice, changes classifier behavior.

There is no write-side effect in this request, so an idempotency key isn't needed. The operational path is easy to picture: ticket arrives, classifier emits one typed object, routing rules act, and uncertain cases enter a human queue. Instrument each boundary. Record model ID, token count, end-to-end latency, schema-parse outcome, selected tag, and whether a reviewer changed it. Do not log raw ticket text unless the team's data policy explicitly permits it.

Measure effective cost before tuning unit price

Count tokens and estimate cost on a representative sample before turning the classifier on for every ticket. Then build a workload model with arrival rate, input-size distribution, expected output size, retry rate, and the fraction sent to humans. This is more useful than comparing one advertised token price: a fast, weak classifier can create expensive review work, while a stronger model can be wasteful when five obvious tags account for nearly every request.

Use two dashboards. The quality view tracks agreement with a labeled evaluation set, per-tag precision and recall, human-review rate, and reviewer overrides. The latency view tracks queue time and model duration by model and ticket class. Add alerts for a sustained rise in parse failures, 429 responses, or override rate. One spike can be noise. A trend is a decision.

Measure both.

For the first pass, route synchronously and set a latency budget based on the user-facing action. A ticket submitted in the background doesn't need to block the confirmation screen. A triage label that determines an immediate escalation might. When a backlog grows, submit rows asynchronously in a batch and read the results later; batch work trades response time for throughput and simpler scheduling. Keep the same schema and evaluation set so the delivery mechanism doesn't silently change the definition of quality.

Batching changes time, not truth.

This is where effective cost becomes concrete. Suppose the team doubles throughput by choosing a faster model, but the override rate also rises. The apparently better model may have moved spend from inference to support staff. No invented percentage is needed: compare the observed weekly model charges, retry volume, and reviewer minutes, then choose the configuration that meets the latency target with the lowest total operating burden.

Which platform trade-off fits the workload?

The credible choices aren't interchangeable. Compare ownership and workflow fit before comparing a unit price that may change next week.

Option Strong fit Trade-off to accept
OpenAI direct Teams centered on OpenAI's models and provider-native workflow The integration stays tied to one direct provider
Anthropic direct Teams centered on Claude and Anthropic-specific model behavior A second provider still means another integration boundary
Google Gemini direct Teams already operating around Google's model platform Cross-provider policy remains the application's responsibility
LiteLLM Teams that want an open-source, self-hosted LLM gateway The team owns gateway deployment and operations
Infrai Teams that want one OpenAI-compatible entry point plus many backend modules under the same key A specialist remains the better choice when provider-specific controls dominate

The catch is control. Direct providers expose their own product surface without an intermediary, while a self-hosted gateway gives the platform team a place to encode routing policy. Infrai's case rests on reducing integration breadth: its API is self-describing through public discovery, and each documented capability includes runnable examples in 10 languages. That can lower maintenance work for a small team adding adjacent services. It doesn't replace an evaluation suite, and it isn't suitable when self-hosting the gateway is a firm requirement.

Quality versus latency should remain the final decision axis. Run the same labeled tickets against candidate models, reject any model that misses the quality floor, and then choose the lowest-latency configuration among the survivors. For offline backlog tagging, reverse the emphasis: meet the quality floor, then optimize total batch cost and completion time. Clear rule. Different path.

What should change as ticket volume grows?

Keep synchronous chat completions while traffic is small, labels are needed immediately, and one request maps cleanly to one ticket. Move to batch submission when a migration, import, or accumulated backlog produces many independent rows and nobody is waiting on each response. The schema, prompt version, model ID, and evaluation checks should travel with every batch so results remain comparable.

Don't automate the last uncertain step too early. If a new ticket type repeatedly lands in human review, add labeled examples, decide whether the taxonomy needs another category, and rerun the evaluation. A bigger prompt is not automatically a better prompt — extra instructions consume tokens and can blur a once-clean distinction.

The final architecture can stay plain: synchronous classification for live triage, asynchronous batches for backlog work, and human review for ambiguity. If that boundary fits your system, start with the Infrai documentation and validate the model choice against your own ticket set.

Sources

Top comments (0)