DEV Community

YorkHolloway3257
YorkHolloway3257

Posted on

Content Moderation-Style Text Labeling Explained — Six Unsafe Spam Tags Without Endpoint

A hiring rubric is not a safety policy, and treating it like one is how a useful classifier becomes an unreviewable gate. For a healthtech team scoring candidate materials, I would use chat completions with a small, explicit JSON label contract, then keep the model's decision separate from the hiring score and route uncertainty to review. The important design choice is the six-tier label set: it makes unsafe, spam, and abuse signals visible without pretending that a language model has made an employment decision.

That separation matters at 3am, when an alert says that a queue is healthy but nobody can answer which tenant's applicant records are accumulating, why a label changed, or what evidence caused a human reviewer to see the item. The model can classify text. It should not silently become the policy.

What failed when the label became the decision

I first assumed the safest shortcut was to send a candidate's free-text response to a general completion request, ask for safe, spam, or abuse, and use the returned word to choose the next workflow. I changed my mind after a response contained a quoted threat from a fictional case study. The classifier saw the threatening words, the hiring workflow treated the response as unsafe, and the audit record contained no distinction between quoted text and an actual threat.

The production lesson was narrower than “models are unpredictable.” The label had been carrying three jobs at once: describing content, selecting a queue, and influencing a candidate evaluation. Those jobs have different review standards. I changed the contract so that content signals were observations, not verdicts. The workflow could pause on a high-risk observation, but the hiring rubric remained a separately versioned calculation with a human-owned review path.

This is also why I do not start with a provider-specific moderation endpoint for this problem. A dedicated safety classifier can be the right boundary when its taxonomy matches the policy and its output is sufficient. A structured completion is useful when the team needs domain labels beside safety signals, such as “contains clinical claim,” “contains personal contact data,” or “requires reviewer context.” The trade-off is yours to own: a general model gives you a flexible schema, while you must supply the policy, examples, validation, and escalation rules.

The invariant is simple: no single free-form answer should be allowed to decide what a tenant is charged for, what a reviewer sees, and whether a candidate advances. That rule survives model changes.

How can content moderation-style text labeling work without an endpoint?

The schema should answer a bounded classification question, not “is this candidate good?” For each text item, I use six values: clear, unsafe, spam, abuse, sensitive, and review. The values describe routing signals, not legal findings or hiring outcomes. review is deliberately boring. It is the place for ambiguity, missing context, and policy cases that the prompt cannot resolve reliably.

A response also needs a short rationale code rather than an essay. Long explanations increase the surface area for accidental personal data and make downstream parsing harder. Store the original input under the tenant's retention policy; store the label, policy version, model metadata, and a stable item identifier in the event used for operations. Do not put raw candidate text into a metric label.

A minimal contract can look like this:

package main

import (
    "encoding/json"
    "fmt"
)

var allowed = map[string]bool{
    "clear":     true,
    "unsafe":    true,
    "spam":      true,
    "abuse":     true,
    "sensitive": true,
    "review":    true,
}

type Label struct {
    Category   string `json:"category"`
    Reason     string `json:"reason_code"`
    Confidence string `json:"confidence"`
}

func validate(raw []byte) (Label, error) {
    var label Label
    if err := json.Unmarshal(raw, &label); err != nil {
        return Label{}, fmt.Errorf("invalid JSON: %w", err)
    }
    if !allowed[label.Category] {
        return Label{}, fmt.Errorf("unknown category %q", label.Category)
    }
    if label.Reason == "" || label.Confidence == "" {
        return Label{}, fmt.Errorf("missing required field")
    }
    return label, nil
}
Enter fullscreen mode Exit fullscreen mode

The Go snippet validates shape after the response arrives; it does not prove that the classification is correct. That distinction is where many implementations go wrong. JSON schema can constrain structure, but structure is not truth. The request should still describe the allowed categories, give counterexamples, and say what to do when context is insufficient.

For a chat-completions style request, keep the instruction and schema versioned together. Ask for one object, reject additional properties when the interface supports that constraint, and treat refusal, truncation, invalid JSON, and transport failure as operational states. Do not retry a policy-sensitive item indefinitely: retries can duplicate work and can make cost attribution look like new tenant activity.

How do you keep tenant cost visible without turning metrics into a privacy leak?

Per-tenant visibility is the decision axis here, but token totals are not enough. Count requests, input and output tokens when the runtime exposes them, retries, validation failures, and human-review transitions. Attribute those events to an internal tenant identifier that is approved for telemetry; never use an email address, candidate name, or raw prompt as a label value. High-cardinality labels can damage the metrics system, while a shared aggregate hides the tenant that is actually creating the queue.

I would record one immutable usage event per attempt, with fields such as tenant_id, item_id, policy_version, schema_version, model_id, attempt, input_tokens, output_tokens, outcome, and created_at. The exact accounting fields depend on the API response and the organization's retention rules. The important part is that an invalid response remains visible as an invalid response; do not quietly bill it as a successful classification or erase it from the operational trail.

A small event shape is enough for a first pass:

type UsageEvent struct {
    TenantID      string `json:"tenant_id"`
    ItemID        string `json:"item_id"`
    PolicyVersion string `json:"policy_version"`
    SchemaVersion string `json:"schema_version"`
    ModelID       string `json:"model_id"`
    Attempt       int    `json:"attempt"`
    InputTokens   int    `json:"input_tokens"`
    OutputTokens  int    `json:"output_tokens"`
    Outcome       string `json:"outcome"`
}
Enter fullscreen mode Exit fullscreen mode

This is not a billing system. It is an audit-friendly measurement boundary from which a billing or budget report may be built. Keep the boundary explicit because tenant visibility and candidate privacy pull in opposite directions. A dashboard that exposes every raw prompt to every operator is not an observability success.

What should happen after a bad or ambiguous response?

The safe path has fewer branches than people expect. Validate the response, persist the attempt outcome, and route it according to policy. An unknown category, missing field, refusal, or exhausted context window should enter review or a technical-failure queue, depending on whether the problem is semantic or operational. It must not silently become clear.

The same applies to prompt injection in candidate text. Treat the text as data inside a clearly delimited field. Do not let instructions found in that text rewrite the classification policy, request secrets, or change the output contract. OWASP identifies prompt injection and insecure output handling among the risks that need application-level controls; a schema is one control, not a complete defense.

A bounded worker can make the transition explicit:

func classify(raw []byte) (Label, string) {
    label, err := validate(raw)
    if err != nil {
        return Label{Category: "review", Reason: "invalid_model_output", Confidence: "unknown"}, "technical_review"
    }
    if label.Category == "review" {
        return label, "human_review"
    }
    return label, "policy_route"
}
Enter fullscreen mode Exit fullscreen mode

The example intentionally does not compute a candidate score. That is a boundary worth defending. A hiring score needs its own rubric, calibration, access control, appeal process, and version history. Content labels can inform a reviewer without becoming a hidden proxy for suitability.

Which implementation boundary survives a provider change?

Keep three interfaces independent: the model client, the label validator, and the policy router. The client knows how to send a request and read usage metadata. The validator knows the JSON contract. The router knows what unsafe, sensitive, or review means for this tenant's workflow. If those concerns share one prompt wrapper and one database write, a model migration becomes a policy migration by accident.

There are several reasonable runtime choices. A direct API client minimizes moving parts but leaves you responsible for retries, quotas, and routing. A self-hosted gateway can centralize authentication, logging, and provider selection, but it adds another availability boundary and another place where sensitive text may be retained. A managed classification service may have a stable taxonomy and simpler operations, but its labels may not map cleanly to a healthtech hiring policy. None of these is universally superior. Compare them on schema guarantees, usage metadata, retention controls, regional processing, failure behavior, and the effort required to reproduce a decision.

I distrust green dashboards that only show request success. The pager should answer: which tenant, which policy version, how many items entered review, how many outputs failed validation, and how much work was retried? Alert on changes in invalid-output rate, review rate, and per-tenant volume, with a sampling policy for inspecting content under the right access controls. The operational artifact is the decision record, not a pretty latency line.

The decision rule

Use structured chat completion labeling when the policy is explicit, the six categories are testable, and a human can handle the ambiguous tail. Keep safety observations separate from candidate scoring. Version the prompt, schema, policy, and evaluator set. Measure per-tenant attempts and outcomes without putting candidate text in telemetry.

Do not use this pattern as an automatic employment gate, as a substitute for a dedicated safety control when the risk requires one, or as permission to infer sensitive traits from candidate material. The pattern is strongest when it narrows a queue and leaves an accountable person with enough context to review the uncertain cases.

That is the part I would want in the incident channel: what page fired, which boundary failed, and whether the system preserved the evidence needed to correct it. A label is useful only when someone can explain what it meant, under which policy, and what happened next.

Sources

Top comments (0)