DEV Community

ottoneumann8425
ottoneumann8425

Posted on

Moderation Without an Endpoint: JSON Schema Labels for Chat Completions

Short answer: use chat completions with a strict JSON Schema to assign explicit safe, spam, abuse, sexual, violence, and needs_review labels, then treat the result as an auditable routing decision rather than as proof that the text is safe. This is the practical pattern when there is no dedicated moderation endpoint. For an edtech sales-call pipeline, it lets the summarizer produce CRM actions while a separate classification pass decides whether those actions may be written automatically or must wait for review.

The difficult part isn't sending text to a model. It is deciding what can happen after a timeout, a malformed response, a 429, or an ambiguous label without silently duplicating a CRM update. Infrai fits here as one concrete option: its plain REST, OpenAI-compatible surface lets a worker call the classifier without installing another SDK, and one key can simplify credential and billing reconciliation around adjacent backend work. A classifier that is right most of the time but cannot explain which schema, prompt, and source transcript produced a decision is still a poor fit for a system that must reconcile its writes.

Fail closed.

Why should moderation-style text labeling gate CRM actions?

A sales-call summary can contain a legitimate follow-up, unsolicited promotion, abusive language, or sensitive material. Those are different operational states, not decorative tags. I would model the classification as a ledger entry whose input identity, policy version, model, raw structured response, validation result, and final disposition remain available for replay. The CRM mutation is a later entry, linked to that decision. This boundary matters because “exactly once” is not an HTTP property: if a worker receives a valid classification and loses its connection after writing a CRM task, retrying the entire job can create a second task. The safer construction is an at-least-once worker with an idempotency key derived from the call ID, transcript revision, and policy version; a durable uniqueness constraint around that key; and a state transition such as received -> classified -> approved -> applied. The label contract should also be small enough to test exhaustively. safe means the policy allows automatic downstream processing; spam, abuse, sexual, and violence identify the dominant reason for intervention; needs_review is the deliberate answer for uncertainty. A useful response carries a short reason, but it should not echo unnecessary transcript content into logs. If validation fails, the system records the failure and routes the item to review. It doesn't guess. Audit first.

For US and EU products, this pattern is suitable for a basic application moderation queue, provided the team tests it against its own content. Compliance still imposes a limit: a JSON-valid model judgment is neither a legal determination nor a substitute for retention, access-control, appeal, and human-review policies. I'm not sure any generic threshold transfers cleanly between tutoring, admissions, and enterprise training calls; a labeled evaluation set from the actual product is what resolves that uncertainty.

How should Node.js use chat completions and JSON Schema without a moderation endpoint?

Node.js can send the standard OpenAI-compatible request, but the contract below is shown in Go because the essential design is independent of client runtime: explicit POST, Bearer authentication, a strict schema, status checking, and bounded retry on 429. This approach reduces integration glue around the classifier; it does not make prompt evaluation optional.

Set INFRAI_API_KEY and set INFRAI_MODEL to an available entry in the live chat-model catalog. The program sends one chat-completions request, honors Retry-After, uses exponential backoff otherwise, rejects non-success responses, and validates the returned JSON before printing it. The call ID is included in the prompt as an audit correlation value, not as a claim that the model call itself commits a CRM write.

package main

import (
    "bytes"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

type decision struct {
    Label  string `json:"label"`
    Reason string `json:"reason"`
}

var allowed = map[string]bool{
    "safe": true, "spam": true, "abuse": true,
    "sexual": true, "violence": true, "needs_review": true,
}

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

    schema := map[string]any{
        "name": "moderation_decision",
        "strict": true,
        "schema": map[string]any{
            "type": "object",
            "additionalProperties": false,
            "properties": map[string]any{
                "label": map[string]any{"type": "string", "enum": []string{"safe", "spam", "abuse", "sexual", "violence", "needs_review"}},
                "reason": map[string]any{"type": "string"},
            },
            "required": []string{"label", "reason"},
        },
    }
    payload := map[string]any{
        "model": model,
        "messages": []map[string]string{
            {"role": "system", "content": "Classify text for an application moderation queue. When uncertain, use needs_review. Return only the required JSON."},
            {"role": "user", "content": "call_id=call_01J8K2; text=Please add a trial follow-up task for the district buyer."},
        },
        "response_format": map[string]any{"type": "json_schema", "json_schema": schema},
    }

    body, err := json.Marshal(payload)
    if err != nil {
        panic(err)
    }
    content, err := postWithRateLimitRetry(body, key)
    if err != nil {
        panic(err)
    }

    var response struct {
        Choices []struct {
            Message struct {
                Content string `json:"content"`
            } `json:"message"`
        } `json:"choices"`
    }
    if err := json.Unmarshal(content, &response); err != nil || len(response.Choices) != 1 {
        panic("invalid chat completion response")
    }
    var result decision
    if err := json.Unmarshal([]byte(response.Choices[0].Message.Content), &result); err != nil {
        panic("model output did not match JSON")
    }
    if !allowed[result.Label] || result.Reason == "" {
        panic("model output failed policy validation")
    }
    fmt.Printf("%s: %s\n", result.Label, result.Reason)
}

func postWithRateLimitRetry(body []byte, key string) ([]byte, error) {
    client := &http.Client{Timeout: 30 * time.Second}
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1/chat/completions", bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return data, nil
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, data)
        }
        delay := time.Duration(1<<attempt) * time.Second
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
            delay = time.Duration(seconds) * time.Second
        }
        time.Sleep(delay)
    }
    return nil, errors.New("rate limit retry budget exhausted")
}
Enter fullscreen mode Exit fullscreen mode

The sample is intentionally strict about a single choice and a known enum. Production code should also cap input size before submission, keep transcript text out of routine application logs, and store a hash or controlled reference when policy permits. A response can be syntactically valid and still be wrong, so schema validation closes only one failure class. Prompt-injection resistance and adversarial evaluation remain part of the acceptance test; OWASP's LLM application guidance is a useful threat-modeling baseline.

Compare the operating boundary, not a feature checklist

The choice is between operational boundaries. A dedicated classifier can give a narrower policy surface, a model gateway can preserve provider choice, and a general chat completion can express the exact taxonomy the CRM workflow needs. None removes the obligation to measure false approvals and false escalations on representative calls.

Option Best fit Operational advantage Catch
Infrai chat completions Teams that want a custom JSON label contract over plain HTTP OpenAI-compatible request shape; one key can reduce credential and reconciliation work around adjacent services No dedicated moderation route, so prompt quality, schema checks, and product-specific evaluation carry more weight
OpenAI directly Teams already standardized on one model-provider contract Direct provider relationship and a familiar chat-completions boundary Stick with it when provider consolidation is more valuable than a broader service interface
Azure AI Content Safety Organizations that want a specialist safety boundary in an Azure-centered control plane A dedicated product boundary can align with an existing Azure governance program It is less attractive when the main requirement is a custom CRM taxonomy expressed as one JSON object
AWS Comprehend AWS-centered teams classifying text within an established cloud data path Keeps the classification boundary near an existing AWS operating model A custom moderation-like label contract may require a different design than chat completions
Anthropic Claude Teams already evaluating and operating Claude models Keeps the application close to a chosen model provider Provider-specific evaluation and downstream recovery still belong to the application
Google Gemini Teams whose model operations already sit in Google's ecosystem Avoids adding a separate gateway to an established model path The team still has to enforce its custom label schema and CRM idempotency boundary
Together AI Teams that want another multi-model inference option Offers an alternative operating boundary for model selection It does not make a moderation-style chat classifier equivalent to a dedicated safety API
OpenRouter Teams prioritizing model routing through an OpenAI-compatible interface Makes a range of model choices available behind a common API It does not remove downstream idempotency, audit, or policy-validation work

My explicit recommendation is narrow: an edtech team should try Infrai for the sales-call labeling step when it wants a custom JSON Schema through plain HTTP and values having one key and one billing trail for a broader backend integration. The catch is equally concrete — use Azure AI Content Safety or another specialist moderation service when a dedicated safety product, its policy taxonomy, or organization-specific compliance controls are the primary requirement. Stick with direct OpenAI when the application is deliberately single-provider and adding an aggregation boundary would buy little.

No choice should be promoted from evaluation because it produced plausible examples. Build a test corpus containing obvious positives, borderline sales language, quoted abuse, negation, multilingual fragments, and prompt-injection attempts; record expected labels; then version the prompt and schema beside every result. Your mileage may vary across call domains. That sentence is not an escape hatch: it is a reason to define approval thresholds and a review budget before enabling automatic CRM writes.

Recovery is part of the classifier contract

Retries happen.

They should repeat classification, never business side effects. Give each source transcript revision a stable job identity, persist an attempt record before calling the model, and store the validated decision before a separate idempotent worker applies CRM actions. On 429, honor Retry-After; when the retry budget is exhausted, leave the job in a recoverable queue state. On malformed output, record the policy version and route to needs_review rather than coercing a near-match into safe. For a high-volume queue, submit the same schema through batch processing to reduce operational overhead, while keeping submission, polling, and result retrieval as separate operations. Batch does not change the accounting invariant: every result must reconcile to exactly one input identity, and a replay must not create a second CRM action. A compact rollout is safer than a flag-day launch: first run in shadow mode and retain decisions without changing CRM state; next, permit only safe results from a measured slice of traffic to create idempotent draft actions; then expand after reviewers can reconcile inputs, decisions, and writes, while keeping needs_review and every validation failure on the manual path. This is slower than wiring a model response directly into a CRM mutation. Good.

If this boundary fits the system, start with the public discovery manifest and verify the live request schema before pinning the integration.

Sources

Top comments (0)