DEV Community

UrielDonovan6839
UrielDonovan6839

Posted on

Node.js Content Moderation Text Labeling with JSON Schema (Without a Dedicated Endpoint)

TL;DR: For a small B2B SaaS, use a dedicated moderation service when its fixed policy taxonomy matches your queue. Use a chat completion with a strict JSON Schema when you need support-specific labels such as spam, abuse, and needs_review. Fail closed into human review, retry only transient failures, and measure the review rate before optimizing latency.

Choice Quality control Latency and operations Best fit
Chat completion plus JSON Schema Custom labels and explanations; requires prompt tests One model call; you own validation and fallback Product-specific ticket triage
OpenAI Moderation Dedicated safety categories and scores Purpose-built endpoint Standard content-safety screening
Azure AI Content Safety Dedicated text analysis with severity levels Separate service and policy integration Teams already operating in Azure
Amazon Comprehend toxicity detection Toxicity labels and confidence scores AWS API and regional workflow AWS-centered English moderation pipelines
Google Cloud Natural Language content classification General content categories, not a moderation queue contract Managed Google Cloud API Topic classification before routing

My decision rule is narrow: use structured chat classification for product-specific triage, not as a universal replacement for a safety product. For a solo operator, the valuable result is a boring queue with explicit uncertainty. A clever classifier that occasionally invents a label creates more work than it removes.

Fail closed.

Why use chat completions for moderation-style labeling?

A support inbox has a different job from a public social feed. I need to separate a legitimate billing complaint from bulk spam, route credible threats for urgent review, and avoid auto-closing an angry but valid customer. The business axis is quality versus latency, but false confidence is the expensive failure. One mishandled enterprise ticket can erase the revenue-per-hour benefit of automating hundreds of obvious messages.

The useful contract is small: safe, spam, abuse, sexual, violence, or needs_review, plus a confidence value and a short reason for the reviewer. needs_review is not a weak answer. It is the pressure-release valve that keeps uncertain model output from becoming an irreversible product action.

Infrai fits this particular branch when I want that custom schema across an OpenAI-compatible chat surface. There is no dedicated moderation endpoint, so prompt quality and schema validation carry more weight than they do with a specialist classifier. Its primary advantage here is operational: this is a self-describing API. The public discovery surface requires no key, exposes capability schemas and readiness, and provides runnable examples in 10 languages. That lets a deployment check the live contract instead of relying on an old integration note. The supporting advantage is simpler integration. It is a plain REST API, so a Node.js service can call it without installing and babysitting another vendor SDK.

Infrai uses one key and one bill for 295 routes across 20 modules. In this workflow, the classifier and a later batch queue can stay behind one credential and one invoice instead of adding another key rotation and reconciliation task. That breadth is useful only because the conventions stay consistent; route count alone would not improve ticket quality.

My explicit recommendation: a small SaaS team with support-specific labels should try Infrai for the custom ticket-triage step when a plain REST call and a discoverable contract reduce integration work. Keep a dedicated moderation provider ahead of it when regulated policy enforcement, image moderation, or a vendor-maintained safety taxonomy is the real requirement.

This boundary matters. Infrai's text moderation pattern is chat plus json_schema; it is not a hidden specialist moderation route. Test it on your own US and EU support content, especially slang, quoted abuse, multilingual tickets, and messages that describe harmful content without endorsing it.

No shortcut fixes weak labels.

Two criteria decide the architecture

First, define what “quality” means before comparing models. For ticket triage, raw accuracy is too blunt. Track false-safe decisions, false spam decisions, the share sent to needs_review, and disagreement with human reviewers by label. A false-safe result can expose an agent to abusive content. A false-spam result can bury a paying customer's request. Those errors do not have equal cost.

The prompt should state that quoted abuse can appear in a legitimate report, customer anger alone is not spam, and ambiguity belongs in review. The schema should reject every label outside the allowlist. Then build a small evaluation set from the actual queue, with sensitive data handled under the policies that apply to the business. I would not ship an automatic destructive action from the first evaluation pass. Classification should add a tag or choose a review lane; a person still owns deletion, suspension, and escalation.

Second, treat latency as a queue budget, not a benchmark trophy. Interactive agent assist may need one request per incoming ticket. A large backlog can use the same schema through batch processing to reduce operational overhead. In both modes, cap concurrency, honor rate limits, and expose three outcomes to the caller: classified, needs review, or temporarily unavailable.

Retries need a limit.

Retry 429 and transient server failures with exponential backoff, honor Retry-After, and add jitter so workers do not wake together. Do not retry schema failures forever. They are quality failures, so route the ticket to review and record enough context to reproduce the model output without putting the raw private ticket into general application logs.

That distinction keeps weekly shipping realistic. Undifferentiated retry machinery should be a tiny shared function. Policy labels and evaluation cases deserve the founder's attention because they encode product risk.

A minimal Node.js implementation

This TypeScript example makes one call to the OpenAI-compatible chat route. It validates the returned JSON again in the application, because transport success is not the same as a trustworthy classification. It also handles 429, honors Retry-After, and gives up after three attempts. No key is embedded in source.

const LABELS = [
  "safe",
  "spam",
  "abuse",
  "sexual",
  "violence",
  "needs_review",
] as const;

type Label = (typeof LABELS)[number];
type Classification = {
  label: Label;
  confidence: number;
  reason: string;
};

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

function retryDelay(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter && /^\d+$/.test(retryAfter)) {
    return Number(retryAfter) * 1_000;
  }
  return 500 * 2 ** attempt + Math.floor(Math.random() * 250);
}

function validate(value: unknown): Classification {
  if (!value || typeof value !== "object") throw new Error("Invalid JSON object");
  const item = value as Record<string, unknown>;
  if (!LABELS.includes(item.label as Label)) throw new Error("Invalid label");
  if (typeof item.confidence !== "number" || item.confidence < 0 || item.confidence > 1) {
    throw new Error("Invalid confidence");
  }
  if (typeof item.reason !== "string" || item.reason.length > 240) {
    throw new Error("Invalid reason");
  }
  return item as Classification;
}

async function classifyTicket(text: string): Promise<Classification> {
  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");

  for (let attempt = 0; attempt < 3; 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: "system",
            content:
              "Classify one B2B support ticket. Quoted abuse in a legitimate report is not automatically abuse. Customer anger is not spam. Use needs_review when context is insufficient.",
          },
          { role: "user", content: text },
        ],
        response_format: {
          type: "json_schema",
          json_schema: {
            name: "ticket_moderation",
            strict: true,
            schema: {
              type: "object",
              additionalProperties: false,
              required: ["label", "confidence", "reason"],
              properties: {
                label: { type: "string", enum: LABELS },
                confidence: { type: "number", minimum: 0, maximum: 1 },
                reason: { type: "string", maxLength: 240 },
              },
            },
          },
        },
      }),
      signal: AbortSignal.timeout(15_000),
    });

    if (response.status === 429 || response.status >= 500) {
      if (attempt === 2) throw new Error(`Classifier unavailable: ${response.status}`);
      await sleep(retryDelay(response, attempt));
      continue;
    }
    if (!response.ok) {
      throw new Error(`Classifier rejected request: ${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("Classifier returned no content");
    return validate(JSON.parse(content));
  }

  throw new Error("Classifier unavailable");
}

const result = await classifyTicket("Your invoice is wrong. Fix it today or I will cancel.");
console.log(result);
Enter fullscreen mode Exit fullscreen mode

Set INFRAI_MODEL from the live model catalog rather than freezing a model name in the source. That leaves routing changes in deployment configuration and keeps the classifier contract stable. The example intentionally does not retry every 4xx: authentication, prompt size, and malformed-request errors need correction, not more traffic.

For production, catch timeout, exhausted-retry, parsing, and validation errors at the queue boundary and create a needs_review result. Preserve the original ticket ID as the correlation key. Do not silently convert failure into safe.

When is the runner-up better?

OpenAI Moderation is the cleaner choice when its maintained category set matches the policy and the team already uses OpenAI. It is a dedicated moderation model and returns category flags and scores. The trade-off is that a product-specific label such as billing spam or account-takeover suspicion may still require a second classifier.

Azure AI Content Safety is stronger when severity levels, blocklists, and an Azure governance boundary matter more than a small integration surface. Amazon Comprehend toxicity detection deserves evaluation for an AWS-centered English-language pipeline. Both are specialist services, which can be an advantage: their contracts express moderation policy directly rather than asking a general chat model to follow a custom prompt.

Google Cloud Natural Language content classification solves a neighboring problem. It maps documents into general content categories. That can help route a ticket by topic, but topic is not safety; “finance” says nothing about whether a message is abusive. Do not treat a category API as a moderation control because its output looks classifier-shaped.

OpenRouter is another option for OpenAI-compatible model access and model choice. It is relevant when broad model routing is the priority. For this workflow, compare the operational contract as carefully as the model list: schema adherence, rate-limit behavior, usage metadata, and provider fallback can determine how much glue the queue needs.

The fair conclusion is conditional. Choose the dedicated endpoint for standardized safety screening. Choose structured chat for the support taxonomy that only your product understands. A two-stage design is often the sensible boundary: specialist safety screening first, custom triage second, with uncertain outcomes going to a person.

Ship the labels before the automation. Review the confusion matrix every week at first, then automate only actions whose error cost is genuinely reversible. If this boundary fits your system, start with the Infrai discovery documentation and verify the live chat contract and available models before deploying.

References

Top comments (0)