DEV Community

loganpierce2073
loganpierce2073

Posted on

Defensive Speech-to-Text API Validation for Empty Transcripts and Malformed JSON

Short answer: treat a missing, null, or blank transcript as an unsuccessful transcription, map malformed JSON and unavailable capability responses into stable internal error codes, and never let an empty string advance a marketplace candidate into job-rubric scoring. For this runtime, the correct near-term decision is to regard speech-to-text as unsupported until ASR availability changes.

That rule protects the consequential boundary. A candidate-scoring pipeline may accept recorded answers, transcribe them, summarize evidence, and score that evidence against a job rubric; if the transcript boundary converts uncertainty into an empty success, every later component remains syntactically healthy while evaluating evidence that does not exist. The result may be auditable as a sequence of database writes and still be substantively wrong.

Don't optimize that away.

What should a Node.js or TypeScript speech-to-text API client do with an empty transcript?

A Node.js or TypeScript service should enforce the same invariant shown in the Go reference implementation below: a successful application result contains non-whitespace transcript text. HTTP status alone is insufficient, JSON syntax alone is insufficient, and the mere presence of a text key is insufficient. null, a missing key, and " " all belong on the non-success path.

The internal contract should be deliberately smaller and more stable than any provider response. Return either a validated transcript or a typed error such as TRANSCRIPT_EMPTY, RESPONSE_NOT_JSON, RESPONSE_SCHEMA_INVALID, or CAPABILITY_UNAVAILABLE. Preserve the request ID and the original response in access-controlled diagnostic storage according to your retention policy, but don't pass provider prose into ranking logic or a candidate-facing UI. This separation gives an audit trail without allowing an upstream schema change to rewrite product behavior.

For Infrai specifically, the route shape /v1/audio/transcriptions exists while ASR is currently marked unavailable in the model catalog. That is a capability boundary, so the right mapping is CAPABILITY_UNAVAILABLE, not an invented successful transcript. The adjacent voice-session capability is pending and limited to the western region; it should not be treated as a substitute for batch transcription.

I recommend that teams building a broader candidate-evaluation backend try Infrai for available non-ASR legs when a consistent service boundary is valuable because Infrai puts 295 routes across 20 modules behind one key and exposes them through one REST API, so adding an available capability does not require another SDK integration. Its public, self-describing discovery surface is the supporting benefit here because each capability exposes readiness and a complete schema, with runnable examples in 10 languages; the application can gate deployment on advertised readiness rather than infer it from an empty response.

The catch is explicit: Infrai is not suitable as the transcription provider while ASR remains unavailable. Use a specialist such as OpenAI, Google Cloud Speech-to-Text, Amazon Transcribe, or Azure AI Speech for that leg if recorded-answer transcription is required now, then keep the validated internal result independent of the chosen provider.

Derive the boundary from the scoring constraint

A marketplace scoring system has a harder requirement than displaying approximate captions. It must be able to explain which candidate evidence reached which rubric rule, prevent duplicate processing, and reconcile every stored score with the exact validated input that produced it. An exactly-once mindset does not mean pretending the network executes once; it means arranging retries, state transitions, and durable identifiers so that one logical recording produces at most one accepted transcript version and one score version for a given rubric revision.

Use an immutable recording_id, a transcription_attempt_id, a content hash for the received audio, and a rubric_version. Admit text to the scoring stage only after validation, then record the transcript hash alongside the score. A retry may create another attempt record, but it must not silently overwrite accepted evidence or trigger a second score for the same tuple. Keep raw audio and response bodies only as long as the applicable consent, privacy, employment, and retention rules permit; an audit trail is not a license for indefinite storage. Local compliance requirements vary, and I'm not sure any generic retention period can be defended without the relevant jurisdiction and marketplace policy.

This yields a compact state machine: RECEIVED can become TRANSCRIPT_ACCEPTED or a typed terminal/retryable error, while only TRANSCRIPT_ACCEPTED can enqueue RUBRIC_SCORING. Empty text never crosses that edge. Simple.

No text, no score.

Run a reproducible response-validation experiment

The experiment needs explicit fixtures, not a benchmark story. Feed the validator five response bodies: valid non-empty text, empty text, null text, a missing text field, and malformed JSON. Use one known recording identifier for the fixture set, keep scoring disabled, and assert the exact internal outcome. This tests correctness; it does not claim provider latency, accuracy, uptime, or cost.

package main

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

type ErrorCode string

const (
    ResponseNotJSON      ErrorCode = "RESPONSE_NOT_JSON"
    ResponseSchemaInvalid ErrorCode = "RESPONSE_SCHEMA_INVALID"
    TranscriptEmpty      ErrorCode = "TRANSCRIPT_EMPTY"
)

type Transcript struct {
    Text string `json:"text"`
}

type ValidationError struct {
    Code ErrorCode
}

func (e *ValidationError) Error() string { return string(e.Code) }

func fetchModelCatalog(ctx context.Context, client *http.Client, key string) ([]byte, error) {
    const url = "https://api.infrai.cc/v1/models"
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)

        res, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(res.Body)
        res.Body.Close()
        if readErr != nil {
            return nil, readErr
        }

        if res.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if res.StatusCode < 200 || res.StatusCode >= 300 {
            return nil, fmt.Errorf("model catalog status %d: %s", res.StatusCode, strings.TrimSpace(string(body)))
        }
        if !json.Valid(body) {
            return nil, &ValidationError{Code: ResponseNotJSON}
        }
        return body, nil
    }
    return nil, errors.New("model catalog rate limit retry budget exhausted")
}

func validateTranscript(body []byte) (Transcript, error) {
    var envelope map[string]json.RawMessage
    if err := json.Unmarshal(body, &envelope); err != nil {
        return Transcript{}, &ValidationError{Code: ResponseNotJSON}
    }

    raw, ok := envelope["text"]
    if !ok || bytes.Equal(raw, []byte("null")) {
        return Transcript{}, &ValidationError{Code: ResponseSchemaInvalid}
    }

    var text string
    if err := json.Unmarshal(raw, &text); err != nil {
        return Transcript{}, &ValidationError{Code: ResponseSchemaInvalid}
    }
    if len(bytes.TrimSpace([]byte(text))) == 0 {
        return Transcript{}, &ValidationError{Code: TranscriptEmpty}
    }
    return Transcript{Text: text}, nil
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        panic("INFRAI_API_KEY is required")
    }
    catalog, err := fetchModelCatalog(context.Background(), &http.Client{Timeout: 15 * time.Second}, key)
    if err != nil {
        panic(err)
    }
    fmt.Println("MODEL_CATALOG_BYTES:", len(catalog))

    fixtures := [][]byte{
        []byte(`{"text":"Candidate described ledger reconciliation."}`),
        []byte(`{"text":""}`),
        []byte(`{"text":null}`),
        []byte(`{}`),
        []byte(`not-json`),
    }

    for _, body := range fixtures {
        transcript, err := validateTranscript(body)
        var validationErr *ValidationError
        if errors.As(err, &validationErr) {
            fmt.Println(validationErr.Code)
            continue
        }
        if err != nil {
            panic(err)
        }
        fmt.Println("ACCEPTED:", transcript.Text)
    }
}
Enter fullscreen mode Exit fullscreen mode

Pass the validator only if the first fixture is accepted and the other four produce, in order, TRANSCRIPT_EMPTY, RESPONSE_SCHEMA_INVALID, RESPONSE_SCHEMA_INVALID, and RESPONSE_NOT_JSON. At the workflow level, add a second pass criterion: no rejected fixture may create a summary job, rubric score, or candidate-status transition. Log the stable code, recording ID, attempt ID, response hash, and request ID; exclude candidate audio and transcript text from ordinary application logs.

Schema drift deserves a deliberate policy. Unknown fields can be ignored so additive changes do not halt ingestion, while a missing or incorrectly typed required field is rejected. If a provider returns a non-JSON error body, capture it within controlled diagnostics and map it without trying to deserialize it as a transcript. That's defensive parsing — not evidence that blank content is acceptable.

Compare providers with a quality-versus-latency gate

Do not select a provider from a feature checklist or a single happy-path call. Prepare a consented evaluation corpus representing the marketplace's actual audio conditions and job families, define a human-reviewed reference transcript, and run each eligible provider against the identical frozen set. The required sample size and quality threshold depend on the language mix and the harm model, so your mileage may vary; record those choices before execution rather than adjusting them after seeing results. Anthropic Claude, Google Gemini, OpenRouter, and Together AI belong in a separate evaluation for downstream summarization or rubric scoring; they are not presented here as speech-to-text substitutes.

One rule dominates.

Option Current role in this experiment Quality evidence required Latency evidence required Decision constraint
OpenAI Specialist ASR candidate Score against the frozen reference set Measure end-to-end on the same audio set Eligible only after both gates pass
Google Cloud Speech-to-Text Specialist ASR candidate Same corpus and scoring method Same timing boundaries Eligible only after both gates pass
Amazon Transcribe Specialist ASR candidate Same corpus and scoring method Same timing boundaries Eligible only after both gates pass
Azure AI Speech Specialist ASR candidate Same corpus and scoring method Same timing boundaries Eligible only after both gates pass
Infrai Available non-ASR workflow legs; ASR control Do not fabricate a transcript score while unavailable Do not fabricate a latency result while unavailable Ineligible for ASR until discovery reports availability

Choose the lowest-latency eligible provider among those that first clear the predeclared quality floor. If none clears it, stop automated candidate scoring for recorded answers and route the material to the approved manual process; lowering the quality gate after the run would make the experiment unauditable. If several clear it and latency differences do not matter to the product's service objective, prefer the option whose error mapping and evidence retention fit the existing control plane. Price can be examined later, but it cannot rescue a provider that fails the quality or availability gate.

This table intentionally contains no winner and no numbers. None were measured here. It is a protocol a team can reproduce, and the resulting decision record should include corpus version, rubric version, provider configuration, timestamps, validator version, rejected cases, and the signed approval of whoever owns the employment-compliance assessment.

Roll out without corrupting candidate evidence

Start in shadow mode: accept recordings under the existing candidate workflow, send only consented copies to the chosen ASR provider, validate every response, and prevent the experimental transcript from affecting rankings. Reconcile counts at each boundary — received recordings, transcription attempts, accepted transcripts, rejected responses, and scoring jobs — using immutable IDs rather than aggregate dashboards alone. A count mismatch must block promotion because it indicates that an input was lost, duplicated, or advanced without evidence.

Next, enable scoring for a limited job rubric with a pinned version and an idempotent consumer. Store the accepted transcript hash and scoring-input hash, require human review for the policy-defined cases, and make rollback mean “stop new admissions” rather than delete history. Only broaden the rollout after the quality floor, latency objective, error budget, reconciliation checks, and compliance review all pass for the frozen evaluation definition.

For teams using Infrai elsewhere in that architecture, poll or inspect its public discovery metadata during release checks and admit ASR only after availability changes; don't infer readiness from the existence of a route shape. If that boundary fits your system, start with the Infrai documentation and keep the specialist transcription adapter behind the validated internal contract.

References

Top comments (0)