Short answer: use a Node.js chat completions API adapter to summarize text, then enforce a local schema gate before a moderation result enters the customer support review queue.
The constraint is structured output, not prose quality. A support moderation queue needs a decision that downstream code can trust: a category, a severity, a short rationale, and no extra fields that quietly change the contract.
My smallest useful design is a narrow Node.js adapter around a chat-completions-style endpoint, followed by local validation. The model may draft the summary. It does not define the queue contract.
How should Node.js summarize text through a chat completions API?
For a human review queue, I would start with four fields: label, severity, summary, and needsHumanReview. The first two drive sorting. The third gives the reviewer context. The last makes the handoff explicit.
That shape is intentionally boring. Boring survives retries, dashboards, and a teammate changing the prompt on a Friday afternoon. A free-form answer does not.
The input below is a report, not a request to make a final enforcement decision. That distinction matters. A summary can assist a reviewer; it should not silently become an irreversible moderation action.
The smallest working implementation
The adapter has two jobs: send one request and parse one response. Validation stays in the application because parsing JSON is not the same as proving that the JSON is useful.
type ModerationSummary = {
label: "spam" | "harassment" | "self_harm" | "other";
severity: 1 | 2 | 3;
summary: string;
needsHumanReview: boolean;
};
type ChatResponse = {
choices?: Array<{ message?: { content?: string } }>;
};
const endpoint = process.env.SUMMARY_API_URL;
const apiKey = process.env.SUMMARY_API_KEY;
if (!endpoint || !apiKey) {
throw new Error("Missing summary service configuration");
}
function parseSummary(value: string): ModerationSummary {
const parsed: unknown = JSON.parse(value);
if (typeof parsed !== "object" || parsed === null) {
throw new Error("Summary is not an object");
}
const item = parsed as Record<string, unknown>;
const validLabel = ["spam", "harassment", "self_harm", "other"].includes(
item.label as string
);
const validSeverity = [1, 2, 3].includes(item.severity as number);
if (
!validLabel ||
!validSeverity ||
typeof item.summary !== "string" ||
typeof item.needsHumanReview !== "boolean"
) {
throw new Error("Summary failed the queue contract");
}
return item as ModerationSummary;
}
export async function summarizeReport(report: string): Promise<ModerationSummary> {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
messages: [
{
role: "system",
content:
"Return JSON only with label, severity, summary, and needsHumanReview.",
},
{ role: "user", content: report },
],
}),
});
if (!response.ok) {
throw new Error(`Summary request failed: ${response.status}`);
}
const payload = (await response.json()) as ChatResponse;
const content = payload.choices?.[0]?.message?.content;
if (!content) {
throw new Error("Summary response has no content");
}
return parseSummary(content);
}
This example deliberately leaves the endpoint and model selection outside the article's contract. The runtime only assumes a JSON response with a first message content string. That keeps provider-specific choices at one boundary and makes the rest of the queue testable with fixtures.
Where the queue actually fails
I first expected malformed JSON to be the annoying case. It is easy to see in logs. The harder failures are valid JSON with the wrong meaning: severity: 0, a new label that the UI does not know, or a sentence in needsHumanReview that JavaScript treats as truthy somewhere else.
There is also a less glamorous failure: duplicate work. A network timeout can happen after the service has accepted the request. Retrying without an idempotency key or a durable job identity can create two summaries for one report. The queue should store a report ID, an attempt number, the contract version, and the validation result. The summary text is not enough to identify work.
Keep the retry policy outside summarizeReport. A short, bounded retry for transport errors is reasonable; a 429 response should use delayed backoff, while a schema error should go to a reviewable failure path. Retrying malformed output unchanged mostly produces more malformed output, while changing the prompt during a retry makes audits harder. The request should carry a stable report ID as its idempotency key when the service supports that contract; otherwise, deduplicate at the queue boundary.
For sensitive reports, log hashes or stable IDs instead of raw report text by default. Record latency, status, validation failures, and token or request usage when the chosen service exposes those fields. Metrics should tell you whether the queue is slow, rejected, or semantically drifting. Those are different incidents.
What changes at scale, and what is the decision rule?
At low volume, a synchronous request keeps the code easy to reason about. At higher volume, a durable queue and a batch-oriented path can improve throughput, but they change the operational contract: results arrive later, partial failures need reconciliation, and reviewers need a visible pending state. Batch processing is a scheduling choice, not a substitute for validation.
I would add a versioned schema and a fixture set before adding another model option. I changed my mind about where to spend the first week: the fixture set is more useful than a second prompt variant because it exposes contract drift before a reviewer does. The fixtures should include ordinary reports, empty text, very long text, mixed languages, quoted instructions, and deliberately invalid responses. A useful benchmark records valid-output rate, p50 and p95 latency, rejection rate, and reviewer correction rate. One aggregate “quality” number hides the exact problem the queue owner needs to fix.
The trade-off is more code around a tiny API call. I accept it. A few dozen lines of validation are cheaper than letting an unrecognized label reach a human-facing workflow and discovering it through a production screenshot.
Choose the runtime boundary that lets you replace the underlying text service without rewriting queue logic. Require local parsing, explicit enums, bounded retries, durable identity, and metrics that separate transport errors from contract errors.
If the output cannot pass that gate, it is not a moderation summary yet. It is an untrusted draft.
Top comments (0)