DEV Community

BriarVoss47291
BriarVoss47291

Posted on

Replayable Node.js Content Moderation Contracts for JSON Chat Completions

Short answer: Treat content moderation as a typed authorization boundary: a Node.js upload should remain pending until a chat-completions-compatible adapter returns a locally validated JSON Schema result for the text and image together, while invalid, late, or unknown results go to review rather than silently becoming approval.

This design keeps the model's job narrow. It classifies supplied content. Application policy makes the release decision, and an eval harness decides whether a new prompt or model is fit to ship. The resulting flow is easy to describe: Node.js accepts and normalizes an upload, an internal Python gate builds the multimodal request, an adapter performs the configured completion, local code validates the response, and the application commits allow, block, or review before persistence or publication.

Don't start with an SDK. Start with that boundary.

Fail closed.

How can a Node.js content moderation flow check text and image safety?

Keep the public Node.js handler responsible for authentication, request limits, file inspection, and lifecycle state. Give the moderation component an opaque item ID, normalized text, and an approved image reference. The item begins in pending; only a validated allow transition makes it publishable. This prevents a slow classifier from racing a database write or a background publisher.

Text and image should enter one decision envelope when their combined meaning matters. A harmless-looking caption can change the interpretation of a picture, and the reverse is also possible. File signatures, decode limits, malware checks, and rate limits still belong before inference. A model isn't a replacement for those deterministic controls.

The three-way result matters too. review is not a softer spelling of allow. It is the explicit destination for uncertainty, policy-version mismatch, malformed structured data, expired image references, and deadlines. A product without a human queue can reject the upload and invite a retry, but it should preserve the distinction internally so operators can see why content did not proceed.

The Python service is an implementation choice, not a protocol requirement. It fits a notebook-to-production workflow because the same pure policy functions can run in an eval notebook and behind an internal endpoint. A team that keeps everything in Node.js can implement the same contract there; the important artifact is the versioned decision object, not the language boundary.

Build the contract before the adapter

The following example is runnable without a network connection. It creates a chat request containing text, an image reference, and a strict structured-output contract, then exercises the policy with a fake completion function. The production adapter supplies the approved endpoint, authentication, timeout, model identifier, and exact response extraction for the compatible service in use. Those deployment values are intentionally not guessed here.

from __future__ import annotations

import json
from dataclasses import dataclass
from typing import Any, Callable, Literal

Decision = Literal["allow", "block", "review"]

RESULT_SCHEMA: dict[str, Any] = {
    "name": "moderation_decision",
    "strict": True,
    "schema": {
        "type": "object",
        "additionalProperties": False,
        "properties": {
            "decision": {
                "type": "string",
                "enum": ["allow", "block", "review"],
            },
            "categories": {
                "type": "array",
                "items": {"type": "string"},
                "uniqueItems": True,
            },
            "confidence": {"type": "number", "minimum": 0, "maximum": 1},
            "policy_version": {"type": "string"},
            "reason": {"type": "string"},
        },
        "required": [
            "decision",
            "categories",
            "confidence",
            "policy_version",
            "reason",
        ],
    },
}


@dataclass(frozen=True)
class ModerationResult:
    decision: Decision
    categories: tuple[str, ...]
    confidence: float
    policy_version: str
    reason: str


def build_chat_request(
    text: str,
    image_url: str,
    model: str,
    policy_version: str,
) -> dict[str, Any]:
    return {
        "model": model,
        "messages": [
            {
                "role": "system",
                "content": (
                    "Classify the supplied text and image under policy "
                    f"{policy_version}. Return only the requested structure."
                ),
            },
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": text},
                    {"type": "image_url", "image_url": {"url": image_url}},
                ],
            },
        ],
        "response_format": {
            "type": "json_schema",
            "json_schema": RESULT_SCHEMA,
        },
    }


def parse_result(raw: str, expected_policy: str) -> ModerationResult:
    value = json.loads(raw)
    fields = {
        "decision",
        "categories",
        "confidence",
        "policy_version",
        "reason",
    }
    if not isinstance(value, dict) or set(value) != fields:
        raise ValueError("unexpected result fields")
    if value["decision"] not in {"allow", "block", "review"}:
        raise ValueError("unknown decision")
    categories = value["categories"]
    if not isinstance(categories, list) or not all(
        isinstance(category, str) for category in categories
    ):
        raise ValueError("categories must be strings")
    if len(categories) != len(set(categories)):
        raise ValueError("categories must be unique")
    confidence = value["confidence"]
    if isinstance(confidence, bool) or not isinstance(confidence, (int, float)):
        raise ValueError("confidence must be numeric")
    if not 0 <= confidence <= 1:
        raise ValueError("confidence is outside its contract")
    if value["policy_version"] != expected_policy:
        raise ValueError("policy version mismatch")
    if not isinstance(value["reason"], str):
        raise ValueError("reason must be a string")
    return ModerationResult(
        decision=value["decision"],
        categories=tuple(categories),
        confidence=float(confidence),
        policy_version=value["policy_version"],
        reason=value["reason"],
    )


def moderate(
    text: str,
    image_url: str,
    model: str,
    policy_version: str,
    complete: Callable[[dict[str, Any]], str],
) -> ModerationResult:
    request = build_chat_request(text, image_url, model, policy_version)
    try:
        result = parse_result(complete(request), policy_version)
    except (KeyError, TypeError, ValueError, json.JSONDecodeError):
        return ModerationResult(
            decision="review",
            categories=("invalid_result",),
            confidence=0.0,
            policy_version=policy_version,
            reason="A validated classification was not available.",
        )
    if result.decision == "allow" and result.confidence < 0.80:
        return ModerationResult(
            decision="review",
            categories=result.categories,
            confidence=result.confidence,
            policy_version=result.policy_version,
            reason="The allow decision was below the configured threshold.",
        )
    return result


def fake_complete(_: dict[str, Any]) -> str:
    return json.dumps(
        {
            "decision": "allow",
            "categories": [],
            "confidence": 0.93,
            "policy_version": "2026-08-01",
            "reason": "No configured category matched.",
        }
    )


if __name__ == "__main__":
    print(
        moderate(
            text="A garden photo",
            image_url="https://media.invalid/items/example",
            model="configured-model",
            policy_version="2026-08-01",
            complete=fake_complete,
        )
    )
Enter fullscreen mode Exit fullscreen mode

The .invalid top-level domain makes the sample image reference deliberately non-routable. In production, the adapter should pass only the assistant's structured result into parse_result. If a service returns an already parsed object, change the adapter boundary rather than spreading provider-specific response shapes through the policy engine.

Notice the double defense: the request asks for a JSON Schema result, and the application validates the result again. Structured generation narrows the possible output, but it doesn't transfer authorization responsibility to a remote model. Local validation rejects extra fields, duplicate categories, Boolean values disguised as numbers, unknown outcomes, stale policy versions, and out-of-range confidence.

Keep category definitions out of the transport schema. The schema owns types and allowed shapes; a versioned policy document owns the meaning of categories; application code maps categories to actions. If a category moves from review to block, that should be a visible policy change that can be replayed against saved, appropriately governed test fixtures — not a silent prompt edit.

Make the eval set the release gate

A useful eval set contains clear allows, clear blocks, ambiguous review cases, adversarial phrasing, multiple languages represented in actual traffic, and pairs where the caption changes the image's meaning. Each fixture needs an expected outcome and policy version. Raw content retention may be inappropriate, so teams should establish access, redaction, consent, and deletion rules before collecting examples; hashes and labels alone are not enough to reconstruct every failure.

Run the same moderate function in the notebook and in deployment tests. Report transitions, not just aggregate accuracy: block to allow is operationally different from review to block. Per-category false allows, false blocks, review rate, and latency expose trade-offs hidden by one headline score. Prompt size and image processing cost belong in that report as well. Shortening instructions can reduce token use, but the smaller prompt ships only if the eval results preserve the policy behavior the team has chosen.

It's hard to know whether a confidence threshold learned on one model, language mix, or image distribution will transfer to another. Your mileage may vary. Calibrate on governed examples that resemble the deployment population, and treat the threshold as versioned application policy rather than universal truth.

A tiny contract suite catches expensive mistakes early. The tests below require no model access, so they can run on every change.

import json


def test_low_confidence_allow_becomes_review() -> None:
    def completion(_: dict) -> str:
        return json.dumps(
            {
                "decision": "allow",
                "categories": [],
                "confidence": 0.61,
                "policy_version": "2026-08-01",
                "reason": "No category matched with high confidence.",
            }
        )

    result = moderate(
        "Caption",
        "https://media.invalid/items/low-confidence",
        "configured-model",
        "2026-08-01",
        completion,
    )
    assert result.decision == "review"


def test_unknown_field_becomes_review() -> None:
    def completion(_: dict) -> str:
        return json.dumps(
            {
                "decision": "allow",
                "categories": [],
                "confidence": 0.99,
                "policy_version": "2026-08-01",
                "reason": "No category matched.",
                "publish_now": True,
            }
        )

    result = moderate(
        "Caption",
        "https://media.invalid/items/extra-field",
        "configured-model",
        "2026-08-01",
        completion,
    )
    assert result.decision == "review"
Enter fullscreen mode Exit fullscreen mode

This is the notebook-to-prod advantage in concrete form: experimentation and runtime share the parser and decision code, while the network adapter stays replaceable.

Failure handling is part of the safety policy

Plan separately for malformed output, authentication rejection, rate limiting, deadline expiry, and unsupported media. For example, a deployment that receives 401 should verify credential scope and header construction, while 429 should enter a bounded retry path only if the request deadline leaves enough time. Neither status should be interpreted as a content decision. Log the status class, attempt count, latency, model alias, adapter version, and policy version; don't place raw text, image URLs, credentials, or model explanations in metric labels. Retries deserve suspicion: two retries can turn one slow moderation call into three calls, multiply prompt cost, and keep a user request open beyond its useful deadline. Use a strict total budget, jittered backoff where appropriate, and idempotent item IDs. Once the budget expires, commit review or keep the item pending according to the product's stated behavior. Never let a late allow overwrite a newer decision made under another policy version. Streaming adds little here. Server-Sent Events provide a one-way server-to-client stream and use the text/event-stream media type, as MDN documents. That mechanism suits progressive output, but a moderation result is small and atomic. Waiting for the complete structured object makes parsing, validation, and authorization easier to reason about. If the wider application streams generated text, check user input before generation starts and design a separate output policy; one input decision cannot authorize content that does not exist yet.

There is a real catch: synchronous multimodal moderation adds latency and creates an external dependency in the upload path. It is not suitable for bulk ingestion that can remain quarantined, and it is unnecessary when an exact deterministic rule fully describes the risk. Use asynchronous classification for offline imports. Stick with local allowlists, parsers, or file rules for narrowly specified formats. Contextual model classification earns its complexity only where language or image meaning actually changes the decision.

An operational handoff that survives model changes

Before rollout, verify in prose what the service will do. The upload remains non-public until a decision commits. The adapter has one total deadline and bounded retries. Every accepted result passes local structural validation and carries the expected policy version. Dashboards separate allow, block, and review, then slice review causes without exposing user content. Alerts cover latency, invalid-result rate, and outcome drift. A rollback restores the model, prompt, schema, threshold, and policy as one tested release unit.

Also rehearse the awkward cases: the same item submitted twice, an image reference expiring during evaluation, a policy deployment racing an in-flight request, and a review worker completing after a user deletes the content. These aren't classifier questions. They are state-machine questions, and a clean pending -> allow | block | review transition with version checks is much easier to audit than a Boolean column updated by several workers.

The final choice is architectural. Keep the model behind a thin adapter if compatible services differ in image support, structured-output behavior, or response envelopes. Keep policy and evals on your side of that adapter. Then a model change is an evaluated dependency update — exciting when it improves the right metrics, boring when it reaches production.

References

Top comments (0)