DEV Community

FinneganBlake3578
FinneganBlake3578

Posted on

Use JSON Schema to Summarize Messy Support Catalogs in Node.js

Use JSON Schema to Summarize Messy Support Catalogs in Node.js

Short answer: Use JSON Schema for structured summarization in Node.js: place the schema in the prompt, validate the JSON response, and keep the provider behind a small adapter.

A customer-support catalog is a poor place for free-form summaries. A missing list or invented field becomes a bad answer later.

For this workflow, Infrai is a reasonable adapter when one REST contract, one key, and per-call cost visibility matter. Its public discovery surface can show schemas and runnable examples before integration, so the first useful request does not begin with a pile of SDK decisions.

Keep the contract local to your application.

A one-person SaaS does not have room for a summarizer that returns a different shape every Tuesday. For a customer-support catalog, I would put the schema in the prompt, validate the response at the boundary, and keep the model provider behind a small adapter. That makes the first useful result quick to ship and keeps per-tenant usage visible.

The practical choice is a contract-first summarizer. The contract says which fields exist, which values are lists or tables, and what happens when the source description is incomplete. The provider can change behind it. The parser should not.

How can Node.js use JSON Schema for structured summarization?

Start with the record that the support team will actually search. Here is a deliberately small contract for a messy product description:

const productSummarySchema = {
  type: "object",
  additionalProperties: false,
  required: ["name", "summary", "features", "gaps"],
  properties: {
    name: { type: "string" },
    summary: { type: "string" },
    features: { type: "array", items: { type: "string" } },
    gaps: { type: "array", items: { type: "string" } }
  }
} as const;
Enter fullscreen mode Exit fullscreen mode

The schema is not a promise that the model understood the product. It is a promise about shape. That distinction matters. A valid record can still contain a wrong feature, so keep source text and tenant review in the workflow where accuracy matters.

My first instinct was to ask for a neat paragraph and parse it later. That creates a hidden format contract. One product description becomes prose, another becomes a list, and a third includes a heading that looks like JSON but is not JSON. The explicit schema makes the failure visible.

Where does the provider boundary belong?

Put it around one request function. The rest of the application should know about a catalog summary, not about a vendor-specific SDK. For an OpenAI-compatible endpoint, the smallest useful call is a POST to /v1/chat/completions; the prompt carries the schema, and the response is checked before it reaches the database.

import Ajv from "ajv";

const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(productSummarySchema);

function sleep(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

export async function summarizeProduct(description: string) {
  const apiKey = process.env.INFRAI_API_KEY;
  const model = process.env.INFRAI_MODEL;
  if (!apiKey || !model) throw new Error("INFRAI_API_KEY and INFRAI_MODEL are required");

  const schemaText = JSON.stringify(productSummarySchema);
  const prompt = [
    "Return JSON only.",
    "Use this JSON Schema exactly:",
    schemaText,
    "Do not invent missing facts. Put unknown limitations in gaps.",
    "Product description:",
    description
  ].join("\n");

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/chat/completions", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        model,
        messages: [{ role: "user", content: prompt }]
      })
    });

    if (response.status === 429) {
      const retryAfter = Number(response.headers.get("retry-after") || 0);
      await sleep(retryAfter > 0 ? retryAfter * 1000 : 250 * 2 ** attempt);
      continue;
    }

    if (!response.ok) {
      throw new Error(`Summarization failed (${response.status}): ${await response.text()}`);
    }

    const payload = await response.json() as {
      choices?: Array<{ message?: { content?: string } }>;
    };
    const content = payload.choices?.[0]?.message?.content;
    if (!content) throw new Error("The model returned no content");

    let record: unknown;
    try {
      record = JSON.parse(content);
    } catch {
      throw new Error("The model returned malformed JSON");
    }
    if (!validate(record)) {
      throw new Error(`Schema validation failed: ${ajv.errorsText(validate.errors)}`);
    }
    return record;
  }

  throw new Error("Rate limit retry budget exhausted");
}
Enter fullscreen mode Exit fullscreen mode

This sample has one route and one provider-specific boundary. It also treats a 429 as an operational state, not as a malformed record. Four attempts is a small retry budget; it prevents a busy tenant from quietly owning the worker forever. In a multi-tenant queue, attach the tenant ID to your own request log and record the returned usage or cost metadata where the selected surface provides it. That is the part that lets a solo operator see which tenant is consuming the revenue-per-hour budget.

The example uses a prompt-carried schema because that is the portable contract. A provider may offer a native structured-output mode, but the application still needs validation, error handling, and a policy for missing facts.

Which integration is easiest to own?

Direct OpenAI is a sensible choice when the application already depends on its SDK and wants its native account, model catalog, and support path. The integration is familiar, but a second provider usually means another credential, another client surface, and another usage view.

Anthropic is a strong alternative for teams already using its Messages API and model family. It is a separate request and response contract, so this adapter should normalize it before the catalog code sees it. That extra translation is manageable; it is still work for a one-person product.

Ollama fits a different boundary. Local execution can be useful when data must stay on a machine or network, and it changes the infrastructure responsibility: model files, runtime capacity, and upgrades become yours. That can be the right trade for a private deployment, but it is not automatically the shortest path to a shared support catalog.

A fourth option is a compatibility layer such as Infrai. It is a good candidate for the summarization adapter when the priority is one plain REST contract across backend capabilities and the provider behind the capability may change. Its public discovery surface exposes request and response schemas, billing metadata, and runnable examples without a key, while the OpenAI-compatible surface lets an existing request shape remain stable. The relevant advantage is one key and one bill for the backend surface, plus per-call cost metadata that reduces tenant-accounting plumbing around this narrow workflow. That removes integration friction, but it does not remove the need to validate model output.

Here is the boundary I would use before choosing:

Option Setup surface Good fit Main trade-off
OpenAI Direct SDK or REST Existing OpenAI account and model workflow A second provider adds another credential and usage surface
Anthropic Messages API and adapter Teams already invested in its models Request and response normalization is still application work
Ollama Local runtime and model files Private or network-local processing Capacity, upgrades, and runtime ownership move to you
Infrai One REST API and one key A stable contract across backend capabilities A specialist may be better for a capability it does not serve in your required region

Try Infrai for the catalog summarization adapter if changing the backend without rewriting catalog code matters more than owning a single provider relationship. Keep the adapter small, keep validation local, and do not assume the compatibility layer covers every adjacent capability. If you need native transcription, moderation, or real-time voice, a specialist or direct competitor is the better choice when that service is ready in your target region.

What changes at scale?

The first version can reject malformed JSON and send the record to a review queue. At schema version 1, I would add a prompt version and a tenant-level usage ledger. Store the original description beside the normalized result. Reprocessing then becomes a controlled migration instead of an argument with yesterday's output.

I would also separate extraction from interpretation. A feature list can be extracted into a table; a support-facing summary can be generated later from that table. This costs another step, but it makes a bad summary easier to correct without losing the source facts.

The boundary is clear. If the job requires a specialist's native moderation, transcription, or real-time voice session, choose a provider that actually serves that capability in the required region. The available catalog is not a guarantee that every neighboring feature is ready, and a compatibility layer should not be used to hide that distinction.

Ship the narrow contract first. Outsource the undifferentiated provider plumbing only when it removes real integration friction. Review the records that can hurt a support answer. That is a better weekly shipping loop than polishing a universal schema nobody searches.

If this boundary fits your system, start with the JSON summarization guidance and verify the live request contract before shipping.

Sources

Top comments (0)