Upload a recording (audio, or video with the camera off), get a private Swedish transcript, and chat about it. - ADR-033: RLS-scoped private transcript store; ffprobe/ffmpeg intake; gateway transcription (iguana kb-whisper, berget fallback); in-process single-replica worker; chat over the transcript (local models only). - ADR-034: optional two-engine mode (pianissimo + kb-whisper) reconciled by a LOCAL model; off unless TAPIR_RECONCILE_BASE_MODEL is set. - ADR-035: real progress (phases, running/quiet/lost), phase logging, every judged spot stored, merged segments. - Hardening from four adversarial reviews: errors logged and stored as kinds never text; per-engine deadlines; converted-length check; bounded queue; client hang-ups are not errors; strict local-alias check. - Dockerfile: static ffmpeg/ffprobe 7.0.2 pinned by sha256 (#34). Built and inspected by hand; CI does not build the image on a PR, so its first CI build is this merge. Uploads stay OFF until TAPIR_UPLOADS_ENABLED is set in the deployment. Closes #33, #34, #42, #43, #44. Co-authored-by: Mathias <mthbqv@gmail.com>
258 lines
10 KiB
Go
258 lines
10 KiB
Go
package transcribe_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"git.d-ma.be/mathias/tapir/internal/adapters/transcribe"
|
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
|
"git.d-ma.be/mathias/tapir/internal/errkind"
|
|
)
|
|
|
|
// The httptest server stands in for the LiteLLM gateway: it is the process
|
|
// boundary, so a fake here is legitimate. The JSON it returns mirrors what the
|
|
// real gateway returned for iguana/kb-whisper and berget/kb-whisper on 2026-10-01
|
|
// (docs/research/audio-length-ceiling-2026-10-01.md). All text is synthetic.
|
|
|
|
const verboseJSON = `{
|
|
"text": " God morgon allihopa.\n Vi går igenom läget.\n",
|
|
"language": "swedish",
|
|
"duration": 4.25,
|
|
"segments": [
|
|
{"id": 0, "start": 0.0, "end": 1.0, "text": " God morgon allihopa."},
|
|
{"id": 1, "start": 1.0, "end": 9.0, "text": " Vi går igenom läget."}
|
|
]
|
|
}`
|
|
|
|
func wavFile(t *testing.T, content string) string {
|
|
t.Helper()
|
|
p := filepath.Join(t.TempDir(), "a.wav")
|
|
require.NoError(t, os.WriteFile(p, []byte(content), 0o600))
|
|
return p
|
|
}
|
|
|
|
type seen struct {
|
|
method, path, auth, expect, contentType string
|
|
contentLength int64
|
|
transferEncoding []string
|
|
fields map[string]string
|
|
file string
|
|
fileField string
|
|
bodyLen int
|
|
}
|
|
|
|
// serve starts a gateway fake that records the request it received and replies
|
|
// with status and body.
|
|
func serve(t *testing.T, status int, reply string, got *seen) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if got != nil {
|
|
got.method, got.path = r.Method, r.URL.Path
|
|
got.auth = r.Header.Get("Authorization")
|
|
got.expect = r.Header.Get("Expect")
|
|
got.contentType = r.Header.Get("Content-Type")
|
|
got.contentLength = r.ContentLength
|
|
got.transferEncoding = r.TransferEncoding
|
|
raw, _ := io.ReadAll(r.Body)
|
|
got.bodyLen = len(raw)
|
|
r.Body = io.NopCloser(bytes.NewReader(raw))
|
|
require.NoError(t, r.ParseMultipartForm(32<<20))
|
|
got.fields = map[string]string{}
|
|
for k, v := range r.MultipartForm.Value {
|
|
got.fields[k] = v[0]
|
|
}
|
|
for field, fhs := range r.MultipartForm.File {
|
|
got.fileField = field
|
|
f, _ := fhs[0].Open()
|
|
b, _ := io.ReadAll(f)
|
|
got.file = string(b)
|
|
}
|
|
}
|
|
w.WriteHeader(status)
|
|
_, _ = io.WriteString(w, reply)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
func TestGateway_SendsTheRequestTheGatewayExpects(t *testing.T) {
|
|
var got seen
|
|
srv := serve(t, 200, verboseJSON, &got)
|
|
g := transcribe.NewGateway(srv.URL+"/v1", "secret-key", "iguana/kb-whisper", srv.Client())
|
|
|
|
_, err := g.Transcribe(context.Background(), wavFile(t, "RIFF-synthetic-pcm"))
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.MethodPost, got.method)
|
|
assert.Equal(t, "/v1/audio/transcriptions", got.path)
|
|
assert.Equal(t, "Bearer secret-key", got.auth)
|
|
assert.Equal(t, "file", got.fileField, "the multipart field is `file`; `audio_file` is rejected as 'Invalid request'")
|
|
assert.Equal(t, "RIFF-synthetic-pcm", got.file)
|
|
assert.Equal(t, "iguana/kb-whisper", got.fields["model"])
|
|
assert.Equal(t, "verbose_json", got.fields["response_format"])
|
|
assert.Equal(t, "sv", got.fields["language"])
|
|
assert.Empty(t, got.expect, "no Expect: 100-continue: it trips the proxy for any body over 1 KB")
|
|
}
|
|
|
|
func TestGateway_SendsAKnownContentLengthNotChunked(t *testing.T) {
|
|
var got seen
|
|
srv := serve(t, 200, verboseJSON, &got)
|
|
g := transcribe.NewGateway(srv.URL+"/v1", "k", "m", srv.Client())
|
|
|
|
_, err := g.Transcribe(context.Background(), wavFile(t, "0123456789"))
|
|
|
|
require.NoError(t, err)
|
|
assert.Empty(t, got.transferEncoding, "a 90-minute WAV is ~170 MB: stream it with a computed length, not chunked and not buffered")
|
|
assert.Equal(t, int64(got.bodyLen), got.contentLength)
|
|
}
|
|
|
|
func TestGateway_NormalisesTheVerboseJSONResponse(t *testing.T) {
|
|
srv := serve(t, 200, verboseJSON, nil)
|
|
g := transcribe.NewGateway(srv.URL+"/v1", "k", "iguana/kb-whisper", srv.Client())
|
|
|
|
got, err := g.Transcribe(context.Background(), wavFile(t, "x"))
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "God morgon allihopa. Vi går igenom läget.", got.Text)
|
|
assert.Equal(t, "iguana/kb-whisper", got.Endpoint, "the serving endpoint is part of the result")
|
|
assert.InDelta(t, 4.25, got.AudioSeconds, 1e-9)
|
|
assert.Equal(t, []domain.Segment{
|
|
{Start: 0, End: 1, Text: "God morgon allihopa."},
|
|
// The final segment ended at 9.0 s for 4.25 s of audio: whisper's last
|
|
// window can run past the end. Clamped to the audio duration.
|
|
{Start: 1, End: 4.25, Text: "Vi går igenom läget."},
|
|
}, got.Segments)
|
|
}
|
|
|
|
func TestGateway_ErrorsDoNotDependOnTheBody(t *testing.T) {
|
|
// {"error":"failed to process audio"} has had at least five distinct causes, so
|
|
// no code may branch on it. Two different bodies must be treated identically.
|
|
for _, reply := range []string{`{"error":"failed to process audio"}`, `{"error":"something else entirely"}`} {
|
|
srv := serve(t, 500, reply, nil)
|
|
g := transcribe.NewGateway(srv.URL+"/v1", "k", "m", srv.Client())
|
|
|
|
_, err := g.Transcribe(context.Background(), wavFile(t, "x"))
|
|
|
|
var se *transcribe.StatusError
|
|
require.ErrorAs(t, err, &se)
|
|
assert.Equal(t, 500, se.Code)
|
|
}
|
|
}
|
|
|
|
func TestGateway_AnEmptyTranscriptIsAFailureNotASuccess(t *testing.T) {
|
|
srv := serve(t, 200, `{"text":" ","language":"swedish","duration":3,"segments":[]}`, nil)
|
|
g := transcribe.NewGateway(srv.URL+"/v1", "k", "m", srv.Client())
|
|
|
|
_, err := g.Transcribe(context.Background(), wavFile(t, "x"))
|
|
|
|
assert.ErrorIs(t, err, transcribe.ErrEmptyTranscript)
|
|
}
|
|
|
|
func TestGateway_MalformedJSONIsAnError(t *testing.T) {
|
|
srv := serve(t, 200, `not json`, nil)
|
|
g := transcribe.NewGateway(srv.URL+"/v1", "k", "m", srv.Client())
|
|
|
|
_, err := g.Transcribe(context.Background(), wavFile(t, "x"))
|
|
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestGateway_MissingAudioFileIsAnError(t *testing.T) {
|
|
g := transcribe.NewGateway("http://127.0.0.1:1/v1", "k", "m", http.DefaultClient)
|
|
|
|
_, err := g.Transcribe(context.Background(), filepath.Join(t.TempDir(), "nope.wav"))
|
|
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
// whisper.cpp wraps its output at ~60 characters, and a wrap can land INSIDE a word:
|
|
// the continuation segment has no leading space, the `text` field joins segments
|
|
// with newlines, and a segment can be a lone ".". Measured on real gateway output
|
|
// 2026-10-01: 7% of a one-hour recording's segments were such fragments. Joining by
|
|
// newline hands the chat model broken words ("nyt\nt"), so Text is rebuilt from the
|
|
// segments: a segment that begins with a space starts a new word, one that does not
|
|
// continues the previous one.
|
|
func TestGateway_RejoinsWordsWhisperCppSplitAcrossSegments(t *testing.T) {
|
|
reply := `{
|
|
"text": " Vi ligger just nu 12 procent över den ursprungliga kalkylen\n.\n Det kräver ett nyt\nt godkännande från beställaren.\n Framförallt på arbetspl\natsen.\n",
|
|
"language": "swedish", "duration": 20,
|
|
"segments": [
|
|
{"start": 0, "end": 4, "text": " Vi ligger just nu 12 procent över den ursprungliga kalkylen"},
|
|
{"start": 4, "end": 5, "text": "."},
|
|
{"start": 5, "end": 8, "text": " Det kräver ett nyt"},
|
|
{"start": 8, "end": 11, "text": "t godkännande från beställaren."},
|
|
{"start": 11, "end": 14, "text": " Framförallt på arbetspl"},
|
|
{"start": 14, "end": 16, "text": "atsen."}
|
|
]}`
|
|
srv := serve(t, 200, reply, nil)
|
|
g := transcribe.NewGateway(srv.URL+"/v1", "k", "iguana/kb-whisper", srv.Client())
|
|
|
|
got, err := g.Transcribe(context.Background(), wavFile(t, "x"))
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Vi ligger just nu 12 procent över den ursprungliga kalkylen. "+
|
|
"Det kräver ett nytt godkännande från beställaren. Framförallt på arbetsplatsen.", got.Text)
|
|
assert.Len(t, got.Segments, 6, "the timed segments are kept as the backend gave them (trimmed)")
|
|
assert.Equal(t, ".", got.Segments[1].Text)
|
|
}
|
|
|
|
func TestGateway_SentenceLevelSegmentsWithNoLeadingSpaceAreJoinedWithASpace(t *testing.T) {
|
|
// berget/kb-whisper returns whole sentences with no leading space; rebuilding by
|
|
// concatenation would glue them ("allihopa!Vi").
|
|
reply := `{"text": "God morgon allihopa!\nVi går igenom läget.", "language": "sv", "duration": 5,
|
|
"segments": [
|
|
{"start": 0.05, "end": 1.2, "text": "God morgon allihopa!"},
|
|
{"start": 1.6, "end": 4.2, "text": "Vi går igenom läget."}
|
|
]}`
|
|
srv := serve(t, 200, reply, nil)
|
|
g := transcribe.NewGateway(srv.URL+"/v1", "k", "berget/kb-whisper", srv.Client())
|
|
|
|
got, err := g.Transcribe(context.Background(), wavFile(t, "x"))
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "God morgon allihopa! Vi går igenom läget.", got.Text)
|
|
}
|
|
|
|
func TestGateway_WithNoSegmentsTheTextFieldIsUsedWithWhitespaceCollapsed(t *testing.T) {
|
|
srv := serve(t, 200, `{"text": " Hej allihopa.\n Vi börjar. ", "duration": 3, "segments": []}`, nil)
|
|
g := transcribe.NewGateway(srv.URL+"/v1", "k", "m", srv.Client())
|
|
|
|
got, err := g.Transcribe(context.Background(), wavFile(t, "x"))
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Hej allihopa. Vi börjar.", got.Text)
|
|
}
|
|
|
|
func TestStatusError_ExposesItsCodeAsAKindAndNotItsSnippet(t *testing.T) {
|
|
err := fmt.Errorf("transcribe: %w", &transcribe.StatusError{Code: 502, Snippet: "echoed: Anders ansvarar"})
|
|
|
|
assert.Equal(t, "http_502", errkind.Of(err))
|
|
}
|
|
|
|
func TestGateway_AnErrorBodyIsBoundedToTwoHundredBytesInTheStatusError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte(strings.Repeat("x", 100_000)))
|
|
}))
|
|
defer srv.Close()
|
|
wav := filepath.Join(t.TempDir(), "a.wav")
|
|
require.NoError(t, os.WriteFile(wav, []byte("RIFF....WAVEfmt "), 0o600))
|
|
|
|
_, err := transcribe.NewGateway(srv.URL, "", "iguana/kb-whisper", srv.Client()).Transcribe(context.Background(), wav)
|
|
|
|
var se *transcribe.StatusError
|
|
require.ErrorAs(t, err, &se)
|
|
assert.LessOrEqual(t, len(se.Snippet), 200, "a gateway's whole body must not become part of an error")
|
|
}
|