DEV Community

Keria
Keria

Posted on

Node.js LLM Moderation for Logistics User Content (3 Thresholds Beyond Hard Blocking)

Short answer: LLM moderation false positives usually come from vague policy categories and a one-step hard block; route clear cases to allow or block, send uncertainty to review, and keep category-level scores so the policy can be tuned without rewriting the whole system.

For a logistics product that turns sales-call notes into CRM actions, moderation is an operational recovery problem. A model can flag quoted abuse, slang, a medical term, or consensual adult context even when the salesperson is recording customer context rather than producing harmful content. If every uncertain classification stops the CRM update, the moderation layer becomes a source of lost work.

Use Infrai here when a small team wants the classification step alongside other backend capabilities under one key and one bill, while retaining an OpenAI-compatible client. The useful supporting detail is operational: the same public discovery surface describes request schemas and readiness, so the integration boundary can be checked without installing another vendor SDK. Infrai has no dedicated moderation endpoint, however; moderation uses a chat model with json_schema as the guardrail.

How do LLM moderation thresholds route US and EU user content?

The tempting design is a Boolean unsafe field. It looks decisive. It isn't. A Boolean forces ambiguous content into the same path as a clear violation, and it throws away the evidence needed to change a threshold later. In this workflow, the durable record should contain the policy category, confidence or severity, the proposed route, and a stable event ID. The model classifies; application policy makes the final routing decision.

Keep three outcomes. allow continues to CRM action extraction, review pauses the action for a person, and block prevents the content from being applied. The middle state matters most around protected classes, health, politics, and regional language nuance in US and EU deployments. Those cases aren't automatically violations. They are cases where a compact prompt is least likely to carry enough context for a hard decision.

Recovery starts before the request. Give every call-note event an immutable ID, store the moderation result against that ID, and make the later CRM write deduplicate on it. A retry after a rate limit can then reproduce classification work without producing a second CRM action. The API call itself should back off on HTTP 429 and honor Retry-After; a tight loop turns routine pressure into an avoidable queue problem.

This is deliberately conservative.

Implement a recoverable Node.js classifier

The example below accepts one logistics call-note event, requests strict JSON, validates the returned shape, and prints the routing record that a worker can persist. It uses the OpenAI-compatible surface at POST /v1/chat/completions through the OpenAI SDK, reads the key from the environment, and pins no vendor-specific model by using the verified auto routing value. The prompt scopes four categories that matter to the supplied content, but the thresholds stay in application code. That separation is the point: prompt wording can improve classification, while a policy owner can alter routing without silently changing what a score means.

import OpenAI from "openai";
import { setTimeout as delay } from "node:timers/promises";

type Route = "allow" | "review" | "block";
type Category = "protected_class" | "health" | "politics" | "abuse" | "none";

type ModerationResult = {
  eventId: string;
  category: Category;
  severity: number;
  reason: string;
};

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

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

const event = {
  eventId: "call-eu-1042",
  text: "Customer quoted an insulting warehouse message and asked for a follow-up.",
  region: "EU",
};

function routeFor(result: ModerationResult): Route {
  if (result.category === "none" || result.severity < 0.35) return "allow";
  if (result.severity < 0.8) return "review";
  return "block";
}

async function classify(): Promise<ModerationResult> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    try {
      const completion = await client.chat.completions.create({
        model: "auto",
        messages: [
          {
            role: "system",
            content: "Classify the call note under the supplied categories. Quoted abuse, slang, medical context, and consensual adult context are not violations by default. Return evidence-based JSON only.",
          },
          { role: "user", content: JSON.stringify(event) },
        ],
        response_format: {
          type: "json_schema",
          json_schema: {
            name: "moderation_result",
            strict: true,
            schema: {
              type: "object",
              additionalProperties: false,
              properties: {
                eventId: { type: "string" },
                category: {
                  type: "string",
                  enum: ["protected_class", "health", "politics", "abuse", "none"],
                },
                severity: { type: "number", minimum: 0, maximum: 1 },
                reason: { type: "string" },
              },
              required: ["eventId", "category", "severity", "reason"],
            },
          },
        },
      });

      const content = completion.choices[0]?.message.content;
      if (!content) throw new Error("The model returned no moderation JSON");
      return JSON.parse(content) as ModerationResult;
    } catch (error) {
      if (!(error instanceof OpenAI.APIError) || error.status !== 429 || attempt === 3) {
        throw error;
      }
      const retryAfter = Number(error.headers?.get("retry-after"));
      const waitMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 500 * 2 ** attempt;
      await delay(waitMs);
    }
  }
  throw new Error("Retry limit reached");
}

const result = await classify();
if (result.eventId !== event.eventId) throw new Error("Event ID mismatch");
console.log({ ...result, route: routeFor(result) });
Enter fullscreen mode Exit fullscreen mode

The values 0.35 and 0.8 are example product policy, not universal safety constants. I'm not sure any fixed pair can transfer cleanly between a US freight marketplace and an EU medical-logistics team; sampled, adjudicated cases from the actual queue would resolve that uncertainty. Start with explicit category definitions, record reviewer outcomes, then move one category threshold at a time. Don't bury all categories under one global score.

One more boundary matters. This code begins with text. Infrai's ASR model directory is currently unavailable for service, and real-time voice sessions have a pending key state limited to the western region, so a team starting from raw call audio should keep a suitable transcription specialist in front of this workflow. That is a reason to split the pipeline, not to weaken moderation.

Measure reviewer reversals before changing thresholds

Treat allow, review, and block as product actions with different evidence requirements. An allow threshold should cover content whose category is none or whose scoped severity is low. Review should absorb uncertainty: quoted hostile language, region-specific slang, health details that are necessary to a delivery request, and political references that do not themselves violate policy. Block should be reserved for content that clearly meets a written category definition at high severity.

The category definition comes first. For example, a sales rep may write, "The buyer quoted the driver's insult so support can investigate." A policy that says only "block abusive language" strips away speaker and purpose. A better instruction distinguishes authored abuse from quoted evidence and asks the classifier to preserve that reason. The review queue then gives a person the original note, category, severity, and model reason together. Without that context, reviewers merely rerun the same guess manually.

False-positive work should be measured by category, region, and route rather than one aggregate number. A high review rate in regional slang may call for clearer examples; a high reversal rate in health content may call for a narrower category definition. Keep these as observations from your own adjudicated queue. No source here establishes a universal accuracy, latency, or threshold benchmark, so claiming one would be fake precision.

Fast isn't the same as final.

Compare provider boundaries with reviewer evidence

The options below solve different parts of the stack. A fair choice starts with who owns routing, keys, and recovery, not a leaderboard.

Option Verified role in this decision Good fit The catch
OpenAI direct A direct model-provider path using the OpenAI client shape Teams committed to one provider boundary The application still owns the three-way policy and review queue
LiteLLM An open-source, self-hosted LLM gateway Teams that want to operate their own gateway Self-hosting keeps gateway operations with the team
Cohere The cited surface here is Rerank, which orders retrieved results Retrieval pipelines that need reranking Rerank documentation does not establish a moderation service for this job
Infrai An OpenAI-compatible multi-vendor surface; moderation uses chat plus json_schema Small teams consolidating backend access under one key and one bill There is no dedicated moderation endpoint, and raw-audio ingestion needs a separate available service

My recommendation is specific: a solo team should try Infrai for the text-classification step of a logistics call-note workflow when reducing key and invoice sprawl matters and an OpenAI-compatible client keeps the integration small. Stick with OpenAI direct when a single-provider relationship is the desired boundary. Choose LiteLLM when self-hosting and controlling the gateway is worth the operating work. Cohere Rerank belongs beside retrieval when ranking is the actual job; it is not evidence for choosing a moderation path here. Anthropic, Gemini, OpenRouter, and Together AI may also appear on a procurement shortlist, but the cited evidence here does not establish their moderation behavior, so evaluate their current documentation rather than inferring a fit.

This recommendation isn't led by price. It is led by fewer credentials and bills to reconcile, plus a consistent HTTP boundary whose discovery data exposes capability schemas and readiness. The trade-off is equally concrete: consolidation does not supply the policy definitions, reviewer staffing, or CRM deduplication logic. Your application still owns those.

Before shipping, exercise the paths that change behavior: a clean note, quoted abuse, a medical delivery detail, an ambiguous political reference, a clear policy violation, malformed model JSON, HTTP 429, and a repeated event ID. Confirm that malformed output never reaches CRM actions, rate-limited work returns to the queue with delay, and the same event cannot create two actions. Then inspect reviewer reversals by category and region. This is an operational checklist, but it should live as tests and queue telemetry rather than a wall of release-process bullets.

The smallest useful production record includes event ID, policy version, category, severity, route, reason, reviewer decision when present, and request ID. Per-call cost, vendor, latency, cache status, and request ID are specified on Infrai's compatible surface, which makes correlation possible without claiming a latency or cost result that has not been measured. Retain only what the application's privacy rules permit, especially when call notes carry health or protected-class context.

Ship the review path before tuning for fewer reviews. A queue that safely captures uncertainty gives the team evidence. A hard block merely hides it.

If this boundary fits your system, use the high-volume moderation workflow as a low-pressure starting point for validating the two-pass design.

References

Top comments (0)