diff --git a/internal/stt/http.go b/internal/stt/http.go index adf04fd..7777b8f 100644 --- a/internal/stt/http.go +++ b/internal/stt/http.go @@ -6,8 +6,11 @@ import ( "encoding/json" "errors" "fmt" + "io" + "math" "net/http" "strconv" + "strings" "time" "github.com/kami/maven/internal/audio" @@ -46,9 +49,19 @@ func NewHTTPTranscriber(url, token, lang string, timeout time.Duration) *HTTPTra // rather than sent to a model that expects something else. var ErrFormat = errors.New("stt: audio is not 16kHz mono pcm_s16le") +// ErrInvalidTranscript means a peer returned HTTP success without a usable +// transcript contract. It is an error so Pair can fall back to mavsttd rather +// than dropping the turn on a valid-looking 200 response. +var ErrInvalidTranscript = errors.New("stt: invalid transcript response") + +// MaxTranscriptResponseBytes bounds the JSON envelope returned by remote STT. +// A transcript is plain text for one utterance, so 64 KiB is already far above +// useful output and keeps a malformed LAN peer from allocating without bound. +const MaxTranscriptResponseBytes int64 = 64 << 10 + type httpTranscript struct { - Text string `json:"text"` - Confidence float64 `json:"confidence"` + Text *string `json:"text"` + Confidence *float64 `json:"confidence"` } // Transcribe posts the raw PCM and reads back the text. @@ -82,11 +95,35 @@ func (t *HTTPTranscriber) Transcribe(ctx context.Context, a audio.Audio) (string return "", 0, fmt.Errorf("stt: remote returned %d", resp.StatusCode) } + raw, err := io.ReadAll(io.LimitReader(resp.Body, MaxTranscriptResponseBytes+1)) + if err != nil { + return "", 0, fmt.Errorf("stt: read transcript: %w", err) + } + if int64(len(raw)) > MaxTranscriptResponseBytes { + return "", 0, fmt.Errorf("%w: response exceeds %d bytes", ErrInvalidTranscript, MaxTranscriptResponseBytes) + } var out httpTranscript - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + if err := json.Unmarshal(raw, &out); err != nil { return "", 0, fmt.Errorf("stt: decode transcript: %w", err) } - return out.Text, out.Confidence, nil + if out.Text == nil || out.Confidence == nil { + return "", 0, fmt.Errorf("%w: text and confidence are required", ErrInvalidTranscript) + } + text := strings.TrimSpace(*out.Text) + if err := validateTranscript(text, *out.Confidence); err != nil { + return "", 0, err + } + return text, *out.Confidence, nil +} + +func validateTranscript(text string, confidence float64) error { + if strings.TrimSpace(text) == "" { + return fmt.Errorf("%w: text is blank", ErrInvalidTranscript) + } + if math.IsNaN(confidence) || math.IsInf(confidence, 0) || confidence < 0 || confidence > 1 { + return fmt.Errorf("%w: confidence %v is outside [0,1]", ErrInvalidTranscript, confidence) + } + return nil } var _ Transcriber = (*HTTPTranscriber)(nil) diff --git a/internal/stt/http_test.go b/internal/stt/http_test.go index 85d3289..f4897bc 100644 --- a/internal/stt/http_test.go +++ b/internal/stt/http_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "strconv" + "strings" "testing" "time" @@ -54,7 +55,7 @@ func TestHTTPTranscriberOmitsEmptyToken(t *testing.T) { var auth string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auth = r.Header.Get("Authorization") - _, _ = io.WriteString(w, `{"text":"x"}`) + _, _ = io.WriteString(w, `{"text":"x","confidence":0}`) })) defer srv.Close() a := audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("x")} @@ -66,6 +67,46 @@ func TestHTTPTranscriberOmitsEmptyToken(t *testing.T) { } } +func TestHTTPTranscriberRejectsInvalidSuccessResponse(t *testing.T) { + t.Parallel() + tests := []struct { + name string + body string + }{ + {name: "empty object", body: `{}`}, + {name: "blank text", body: `{"text":" ","confidence":0.5}`}, + {name: "missing confidence", body: `{"text":"hello"}`}, + {name: "negative confidence", body: `{"text":"hello","confidence":-0.1}`}, + {name: "confidence above one", body: `{"text":"hello","confidence":1.1}`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, tc.body) + })) + defer srv.Close() + _, _, err := NewHTTPTranscriber(srv.URL, "", "ru", time.Second). + Transcribe(context.Background(), audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("x")}) + if !errors.Is(err, ErrInvalidTranscript) { + t.Fatalf("error = %v, want ErrInvalidTranscript", err) + } + }) + } +} + +func TestHTTPTranscriberRejectsOversizeResponse(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, strings.Repeat("x", int(MaxTranscriptResponseBytes+1))) + })) + defer srv.Close() + _, _, err := NewHTTPTranscriber(srv.URL, "", "ru", time.Second). + Transcribe(context.Background(), audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("x")}) + if !errors.Is(err, ErrInvalidTranscript) { + t.Fatalf("error = %v, want ErrInvalidTranscript", err) + } +} + func TestHTTPTranscriberRefusesWrongFormat(t *testing.T) { t.Parallel() a := audio.Audio{Format: audio.Format{SampleRate: 44100, Channels: 2, SampleBits: 16, Encoding: "pcm_s16le"}} diff --git a/internal/stt/pair.go b/internal/stt/pair.go index 2001ed6..909e0d3 100644 --- a/internal/stt/pair.go +++ b/internal/stt/pair.go @@ -142,6 +142,9 @@ func (p *Pair) Transcribe(ctx context.Context, a audio.Audio) (string, float64, } if p.Available() { text, conf, err := p.remote.Transcribe(ctx, a) + if err == nil { + err = validateTranscript(text, conf) + } if err == nil { log.Print("stt: transcribed on the workstation") return text, conf, nil diff --git a/internal/stt/pair_test.go b/internal/stt/pair_test.go index b14b241..f5ef9c3 100644 --- a/internal/stt/pair_test.go +++ b/internal/stt/pair_test.go @@ -86,6 +86,24 @@ func TestPairFallsBackWhenRemoteFails(t *testing.T) { } } +func TestPairFallsBackWhenRemoteReturnsBlankSuccess(t *testing.T) { + t.Parallel() + remote := &scripted{text: " "} + floor := &scripted{text: "с homesrv"} + p := up(remote, floor) + + text, _, err := p.Transcribe(context.Background(), sample()) + if err != nil { + t.Fatalf("invalid remote success must fall back: %v", err) + } + if text != "с homesrv" || floor.calls.Load() != 1 { + t.Fatalf("got text=%q floor calls=%d, want floor transcript once", text, floor.calls.Load()) + } + if p.Available() { + t.Fatal("invalid remote response must correct cached availability") + } +} + func TestPairWithNoRemoteIsTheFloor(t *testing.T) { t.Parallel() floor := &scripted{text: "с homesrv"}