Use chat completions with schema-like JSON instructions, then reject any response that fails a local contract before a moderation report reaches a human reviewer. The deciding constraint isn't prose quality; it is whether every accepted result has a title, bullets, key takeaways, and a classification that downstream code can consume without guessing.
For this system, the architecture decision is to treat model output as untrusted input. Keep one application-owned schema, select a model with reliable instruction following from the model catalog, count tokens before sending unusually large reports, and record enough evidence to reconcile every automated decision with its source report. A single chat request can return readable summary text and machine-usable fields, so a separate extraction service adds little unless another workflow needs it.
This is an exactly-once mindset applied to an operation that may be retried: the model may run more than once, but only one validated result for a given report revision is admitted to the review queue.
What can a summary JSON API admit as title, bullets, and key takeaways?
The first invariant is contract validity. A successful HTTP response is only transport success. Application success requires valid JSON, exactly the required fields, bounded arrays, allowed classification values, and no unknown keys. If validation fails, preserve the report for human review rather than coercing an almost-correct value into the database. Silent repair destroys the distinction between what the model emitted and what the application inferred.
The second invariant is source identity. Give each moderation report a stable ID and a revision, hash the exact text submitted, and bind the accepted output to that tuple. If report rep_1842 changes from revision 6 to revision 7 while a request is in flight, the revision 6 result must not overwrite the newer record. This is the same reconciliation problem found in a ledger: identity plus ordering matters more than a plausible final balance.
The third invariant is an audit trail. Record the chosen model ID, prompt version, schema version, source hash, request ID when available, validation outcome, and final disposition. Don't log unrestricted report text merely because it is convenient; moderation material can contain personal or regulated data, and retention, access, and deletion obligations vary by jurisdiction and policy. The classification should remain advisory until the relevant compliance and risk owners approve automation beyond human triage.
The fourth invariant is bounded failure. A 429 is retryable after the server's Retry-After interval or exponential backoff. An authentication or request error is surfaced with its response body, while malformed model content is a contract failure and goes to the review path. These categories must stay separate in metrics because increasing retries cannot cure an invalid schema.
Consider a concrete race involving report rep_1842. Revision 6 contains a claim about a leaked credential, dispatches for classification, and then receives an editor correction as revision 7 before the model response arrives. A transport-oriented implementation sees a successful response and updates the current row; the reviewer now sees a polished summary of superseded evidence. A contract-oriented implementation compares report ID, revision, source hash, prompt version, and schema version inside one admission transaction, records the stale result without promoting it, and leaves revision 7 eligible for classification. If a 429 caused two attempts for revision 7, a uniqueness constraint still admits one validated result. The details are mundane — which is precisely why they belong in the design rather than in an operator's memory.
No coercion.
One trap is worth naming. Structured output doesn't reduce the token cost of a large input by itself. Count before dispatch, apply an explicit size policy, and never truncate a report invisibly; an omitted sentence may be the sentence that changes the classification.
Before dispatch: freeze the evidence envelope
The prompt should define the output as a closed record, not ask for “JSON” and hope for the best. For a moderation report, a useful contract has title, bullets, key_takeaways, classification, and action_items. It also says which fields are required, which enum values are allowed, and that no surrounding markdown is permitted. The natural-language title and bullets remain displayable, while the classification and action items drive a controlled workflow.
Keep the schema in application code and put its version in the prompt. On receipt, decode strictly and validate again locally. This double boundary matters because instruction following is probabilistic, whereas admission to a review queue must be deterministic.
Short prompts aren't automatically better. A compact contract with one representative input/output fixture often provides more useful constraint than several paragraphs of stylistic instruction, although the best fixture depends on the report distribution. I'm not sure which model will follow this particular contract most reliably without a fixture-based evaluation; the catalog narrows the candidates, but only tests using redacted examples from the real workload resolve that question.
At dispatch: score four surfaces with identical fixtures
The vendors are less important than the boundary around them. Evaluate each candidate with the same frozen reports and the same validator, then compare first-pass validity, semantic agreement with reviewers, and behavior at the input limits you actually accept. Do not promote a model because it produces nicer prose when its records fail admission more often.
| Option | Contract strategy | Where it fits | The catch |
|---|---|---|---|
| OpenAI direct | Use chat or function-calling conventions and retain local validation | Teams standardizing directly on the OpenAI surface | Direct coupling is reasonable only when provider substitution is not an architectural requirement |
| Anthropic direct | Submit the same application contract and score it with the same fixtures | Teams already operating an Anthropic-specific integration | Keep it when its native workflow is intentional; switching later still crosses a vendor-specific boundary |
| Google Gemini direct | Apply the shared acceptance tests before standardizing the schema | Teams whose existing platform decision already favors Gemini | Platform alignment does not remove the need for strict decoding and reconciliation |
| Infrai | Keep an OpenAI-compatible chat contract while routing the model behind that contract | Systems that want vendor substitution without changing application code | It is not suitable when the organization requires a vendor-native feature outside the common contract |
| Deterministic rules | Parse known report templates without a generative call | Narrow, stable taxonomies with explicit lexical rules | Rules become expensive when language and report shape vary, but they remain the safer choice where model judgment is prohibited |
Infrai is a strong fit when the portability boundary is the central requirement: the application keeps one compatible REST contract while the provider behind the capability can change. For Infrai, the “one key for everything, one bill” convention also reduces a different class of friction in this workflow: platform operators reconcile one credential and one billing record while model selection changes behind the contract, instead of adding secrets and invoice mappings for each candidate. Its public discovery surface exposes readiness without a key, so deployment checks need not assume it. There is no dedicated moderation endpoint, so text moderation here still uses a chat model plus the JSON contract; teams that need a specialized moderation product should keep that requirement outside this comparison.
OpenAI, Anthropic, and Gemini should remain genuine candidates, not decorative names in a table. Stick with a direct integration when a native feature, an existing enterprise control plane, or a deliberately single-vendor operating model matters more than substitution. Your mileage may vary because model behavior depends on the reports and schema, not the logo.
At admission: make one state transition in Go
The following program sends one report to the verified chat-completions route, retries rate limits, rejects non-success responses, and decodes the returned content strictly. It uses a model ID supplied by deployment configuration; that ID should be selected from the live model catalog before rollout. The sample uses the standard library so every status and byte crossing the boundary stays visible.
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type summary struct {
Title string `json:"title"`
Bullets []string `json:"bullets"`
KeyTakeaways []string `json:"key_takeaways"`
Classification string `json:"classification"`
ActionItems []string `json:"action_items"`
}
type chatResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
func main() {
result, err := classify(context.Background(), "rep_1842", "A package report says the README contains a credential.")
if err != nil {
panic(err)
}
fmt.Printf("%s: %s\n", result.Classification, result.Title)
}
func classify(ctx context.Context, reportID, reportText string) (summary, error) {
key, model := os.Getenv("INFRAI_API_KEY"), os.Getenv("MODEL_ID")
baseURL := strings.TrimRight(os.Getenv("AI_BASE_URL"), "/")
if key == "" || model == "" || baseURL == "" {
return summary{}, errors.New("INFRAI_API_KEY, MODEL_ID, and AI_BASE_URL are required")
}
prompt := `Return one JSON object and no markdown. Schema version: moderation-summary/v1.
Required keys: title, bullets, key_takeaways, classification, action_items.
Every key is required; no other keys are allowed. title is a non-empty string.
bullets, key_takeaways, and action_items are arrays of non-empty strings.
classification is one of: allow, escalate, reject.
Report ID: ` + reportID + "\nReport: " + reportText
payload := map[string]any{
"model": model,
"messages": []map[string]string{{"role": "user", "content": prompt}},
}
body, err := json.Marshal(payload)
if err != nil {
return summary{}, err
}
var responseBody []byte
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
baseURL+"/v1/chat/completions", bytes.NewReader(body))
if err != nil {
return summary{}, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return summary{}, err
}
responseBody, err = io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if err != nil {
return summary{}, err
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := retryDelay(resp.Header.Get("Retry-After"), attempt)
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return summary{}, ctx.Err()
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return summary{}, fmt.Errorf("chat request failed (%d): %s", resp.StatusCode, responseBody)
}
break
}
if len(responseBody) == 0 {
return summary{}, errors.New("rate limit retry budget exhausted")
}
var wire chatResponse
if err := json.Unmarshal(responseBody, &wire); err != nil || len(wire.Choices) != 1 {
return summary{}, errors.New("unexpected chat response")
}
var out summary
decoder := json.NewDecoder(strings.NewReader(wire.Choices[0].Message.Content))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&out); err != nil {
return summary{}, fmt.Errorf("invalid summary contract: %w", err)
}
if out.Title == "" || len(out.Bullets) == 0 || len(out.KeyTakeaways) == 0 || !validClass(out.Classification) {
return summary{}, errors.New("summary failed required-field validation")
}
return out, nil
}
func retryDelay(value string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
return time.Second * time.Duration(1<<attempt)
}
func validClass(value string) bool {
return value == "allow" || value == "escalate" || value == "reject"
}
This boundary is intentionally unforgiving.
In production, persist the source hash and schema version beside the accepted result, and place a uniqueness constraint on report ID plus revision before enqueueing human review. The API call itself may be repeated after a rate limit, but admission remains idempotent. That is the useful form of “exactly once” here; claiming the network executes exactly once would be fiction.
Outside the queue: where free-form parsing still belongs
The rejected design is free-form generation followed by best-effort field extraction. It appears flexible, but it creates two sources of interpretation: the model's prose and the extractor's guesses. A missing classification can become an empty string, a new heading can shift the parser, and reconciliation no longer reveals which component made the decision. Don't use that architecture for a moderation queue whose records must be audited.
There are valid reversals. Choose deterministic rules when policy forbids generative classification or when the input grammar is small and stable. Choose a vendor-native integration when a required control is unavailable through the common chat contract. Retain free-form text only for a human-readable note that never controls routing, storage state, or an enforcement action.
The final acceptance test is plain: given the same frozen fixture, every candidate must return a locally valid record, preserve all evidence needed for review, and fail closed into the human queue. Correct shape comes before fluent wording.
Top comments (0)