feat: upload audio, get a private Swedish transcript, discuss it (#33, ADR-033) (#39)
CI / Deploy via GitOps (push) Successful in 7s
CI / Lint / Test / Vet (push) Successful in 33s
CI / Build & Import (push) Successful in 23s

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>
This commit was merged in pull request #39.
This commit is contained in:
2026-10-03 14:13:37 +00:00
committed by mathias
parent 4bafd5f082
commit 4a2067063d
104 changed files with 14214 additions and 311 deletions
+62
View File
@@ -0,0 +1,62 @@
package audio
import (
"context"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
)
// FFmpeg converts any audio container to the strict format whisper wants: 16 kHz
// mono signed 16-bit PCM WAV (ADR-033). It ALWAYS transcodes, even when the input
// already is that format — a Teams export often is, and the transcode is then
// nearly free; assuming it would be the bug. This replaces whisper-server's
// --convert, whose --tmp-dir is created at process start and rots under macOS
// periodic cleanup.
type FFmpeg struct {
// Bin is the ffmpeg executable ("ffmpeg" resolves via PATH).
Bin string
// MaxSeconds caps the converted length when positive. The duration limit is a claim
// in the file's header and can be lied about (a 2 h file claiming 10 s converted to a
// 120-minute WAV); this bounds what a lie can cost. A minute of slack is added so a
// file at the limit is never cut, and the worker measures the result.
MaxSeconds int
}
// lengthSlackSeconds is added to MaxSeconds so ffmpeg's rounding never clips a file that
// is really at the limit.
const lengthSlackSeconds = 60
// ToWAV writes src, converted, to dst. Video streams are dropped (-vn): by now the
// file has already been probed as audio-only, but a cover-art stream must not end
// up in the WAV.
func (f *FFmpeg) ToWAV(ctx context.Context, src, dst string) error {
bin := f.Bin
if bin == "" {
bin = "ffmpeg"
}
// Input restrictions go BEFORE -i: placed after it they would apply to the output.
args := append([]string{"-y", "-v", "error"}, inputRestrictions...)
args = append(args, "-i", src, "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le")
if f.MaxSeconds > 0 {
// After -i: an OUTPUT cap. Before it, it would limit what is read, not written.
args = append(args, "-t", strconv.Itoa(f.MaxSeconds+lengthSlackSeconds))
}
args = append(args, "-f", "wav", dst)
cmd := exec.CommandContext(ctx, bin, args...)
cmd.WaitDelay = killGrace
if out, err := cmd.CombinedOutput(); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return fmt.Errorf("audio: ffmpeg: %w", ctxErr)
}
return fmt.Errorf("audio: ffmpeg: %w: %s", err, strings.TrimSpace(string(out)))
}
// A zero exit that wrote nothing is still a failure: never hand whisper a
// missing file and let the error surface three layers later.
if info, err := os.Stat(dst); err != nil || info.Size() == 0 {
return fmt.Errorf("audio: ffmpeg wrote no output at %s", dst)
}
return nil
}
+111
View File
@@ -0,0 +1,111 @@
package audio_test
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/adapters/audio"
)
func TestFFmpegToWAV_AsksForSixteenKilohertzMonoPCMAlways(t *testing.T) {
// Always transcode, even when the input already is 16 kHz mono: a Teams export
// often is, and the point is to not ASSUME (ADR-033). Records the argv.
rec := filepath.Join(t.TempDir(), "argv")
bin := stub(t, `printf '%s\n' "$@" > `+rec+`
for a; do last="$a"; done
printf data > "$last"`)
err := (&audio.FFmpeg{Bin: bin}).ToWAV(context.Background(), "/spool/in", filepath.Join(t.TempDir(), "out.wav"))
require.NoError(t, err)
raw, err := os.ReadFile(rec)
require.NoError(t, err)
argv := strings.Split(strings.TrimSpace(string(raw)), "\n")
joined := " " + strings.Join(argv, " ") + " "
for _, want := range []string{" -ac 1 ", " -ar 16000 ", " -c:a pcm_s16le ", " -vn ", " -i /spool/in "} {
assert.Contains(t, joined, want)
}
assert.True(t, strings.HasSuffix(strings.TrimSpace(joined), "out.wav"), "the output path is the last argument")
}
func TestFFmpegToWAV_NonzeroExitIsAnErrorCarryingStderr(t *testing.T) {
bin := stub(t, `echo "moov atom not found" >&2; exit 1`)
err := (&audio.FFmpeg{Bin: bin}).ToWAV(context.Background(), "/in", filepath.Join(t.TempDir(), "o.wav"))
require.Error(t, err)
assert.Contains(t, err.Error(), "moov atom not found")
}
func TestFFmpegToWAV_ZeroExitWithNoOutputIsAnError(t *testing.T) {
bin := stub(t, `exit 0`)
err := (&audio.FFmpeg{Bin: bin}).ToWAV(context.Background(), "/in", filepath.Join(t.TempDir(), "never-written.wav"))
require.Error(t, err)
}
func TestFFmpegToWAV_MissingBinaryIsAnError(t *testing.T) {
err := (&audio.FFmpeg{Bin: filepath.Join(t.TempDir(), "no-ffmpeg")}).ToWAV(context.Background(), "/in", "/out.wav")
require.Error(t, err)
assert.False(t, errors.Is(err, context.Canceled))
}
func TestFFmpegToWAV_RestrictsWhatTheFileMayMakeFFmpegOpen(t *testing.T) {
rec := filepath.Join(t.TempDir(), "argv")
bin := stub(t, `printf '%s\n' "$@" > `+rec+`
for a; do last="$a"; done
printf data > "$last"`)
err := (&audio.FFmpeg{Bin: bin}).ToWAV(context.Background(), "/spool/in", filepath.Join(t.TempDir(), "o.wav"))
require.NoError(t, err)
args := argvOf(t, rec)
pw, fw, in := indexOf(args, "-protocol_whitelist"), indexOf(args, "-format_whitelist"), indexOf(args, "-i")
require.GreaterOrEqual(t, pw, 0, "argv: %v", args)
require.GreaterOrEqual(t, fw, 0, "argv: %v", args)
assert.Equal(t, "file,pipe", args[pw+1])
assert.Equal(t, audio.AllowedDemuxers, args[fw+1])
assert.Less(t, pw, in, "input options must come BEFORE -i or ffmpeg applies them to the output")
assert.Less(t, fw, in)
}
func TestFFmpegToWAV_PassesAnOutputLengthCapAfterTheInputWhenAMaximumIsSet(t *testing.T) {
// The duration limit is a CLAIM in the file's header; a file that lies about it can
// convert to hours of audio (adversarial review F2: a 2 h file claiming 10 s became a
// 120-minute WAV). The cap is an OUTPUT option, after -i, with a minute of slack.
rec := filepath.Join(t.TempDir(), "argv")
bin := stub(t, `printf '%s\n' "$@" > `+rec+`
for a; do last="$a"; done
printf data > "$last"`)
err := (&audio.FFmpeg{Bin: bin, MaxSeconds: 5400}).ToWAV(context.Background(), "/spool/in", filepath.Join(t.TempDir(), "out.wav"))
require.NoError(t, err)
raw, err := os.ReadFile(rec)
require.NoError(t, err)
joined := " " + strings.Join(strings.Split(strings.TrimSpace(string(raw)), "\n"), " ") + " "
assert.Contains(t, joined, " -t 5460 ", "limit plus a minute of slack")
assert.Less(t, strings.Index(joined, " -i /spool/in "), strings.Index(joined, " -t 5460 "), "-t after -i is an output cap; before it, an input one")
}
func TestFFmpegToWAV_PassesNoLengthCapWhenNoMaximumIsSet(t *testing.T) {
rec := filepath.Join(t.TempDir(), "argv")
bin := stub(t, `printf '%s\n' "$@" > `+rec+`
for a; do last="$a"; done
printf data > "$last"`)
require.NoError(t, (&audio.FFmpeg{Bin: bin}).ToWAV(context.Background(), "/spool/in", filepath.Join(t.TempDir(), "out.wav")))
raw, err := os.ReadFile(rec)
require.NoError(t, err)
assert.NotContains(t, " "+strings.ReplaceAll(string(raw), "\n", " ")+" ", " -t ")
}
+109
View File
@@ -0,0 +1,109 @@
// Package audio holds the adapters that inspect and convert user-supplied audio
// with ffmpeg's tools (ADR-033). They sit behind small ports so the use case is
// tested with fakes and only this package knows a subprocess is involved.
package audio
import (
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"strconv"
"time"
"git.d-ma.be/mathias/tapir/internal/uploads"
)
// FFprobe implements uploads.Prober by running ffprobe on the spooled file.
type FFprobe struct {
// Bin is the ffprobe executable ("ffprobe" resolves via PATH).
Bin string
}
// AllowedDemuxers are the input formats an upload may be. The input is
// attacker-supplied, and a file's CONTENT can tell ffmpeg to open other files or
// URLs (HLS and concat playlists, image2 sequences, network demuxers). Recent
// ffmpeg refuses the ones tried (7.0.2: HLS without a .m3u8 name is not sniffed,
// concat rejects unsafe paths), but that is version luck; this list is not. It
// holds the containers real recordings use and none of the playlist or network
// formats. "mov" covers .mp4/.m4a/.3gp; "matroska" covers .webm.
const AllowedDemuxers = "wav,mov,mp4,m4a,3gp,mp3,ogg,matroska,webm,flac,aac,opus,aiff,amr,asf"
// inputRestrictions are ffmpeg/ffprobe INPUT options and must precede the input.
// Local files and pipes only: no network, no other protocols.
var inputRestrictions = []string{"-protocol_whitelist", "file,pipe", "-format_whitelist", AllowedDemuxers}
// killGrace bounds how long Probe waits for a cancelled ffprobe's pipes to close.
const killGrace = 2 * time.Second
// Probe runs ffprobe and classifies the outcome. A non-zero exit means ffprobe
// looked at the file and could not read it: that is the FILE's fault and returns
// uploads.ErrUnreadable. A missing binary, a cancelled context or unparsable
// output is OURS and is returned as an ordinary error, so it is never reported to
// the user as their file being bad.
func (f *FFprobe) Probe(ctx context.Context, path string) (uploads.Probe, error) {
bin := f.Bin
if bin == "" {
bin = "ffprobe"
}
args := append([]string{"-v", "error"}, inputRestrictions...)
args = append(args, "-print_format", "json", "-show_format", "-show_streams", path)
cmd := exec.CommandContext(ctx, bin, args...)
cmd.WaitDelay = killGrace
out, err := cmd.Output()
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return uploads.Probe{}, fmt.Errorf("audio: ffprobe: %w", ctxErr)
}
var exit *exec.ExitError
if errors.As(err, &exit) {
return uploads.Probe{}, fmt.Errorf("%w: ffprobe: %s", uploads.ErrUnreadable, exit.Stderr)
}
return uploads.Probe{}, fmt.Errorf("audio: run ffprobe: %w", err)
}
p, err := ParseProbe(out)
if err != nil {
return uploads.Probe{}, fmt.Errorf("audio: ffprobe output: %w", err)
}
return p, nil
}
// probeJSON is the subset of `ffprobe -print_format json` output that matters.
type probeJSON struct {
Streams []struct {
CodecType string `json:"codec_type"`
Disposition struct {
AttachedPic int `json:"attached_pic"`
} `json:"disposition"`
} `json:"streams"`
Format struct {
Duration string `json:"duration"`
} `json:"format"`
}
// ParseProbe turns ffprobe's JSON into a uploads.Probe. A video stream that is
// only attached cover art (an mp3/m4a with an album picture) is not video. A
// missing or "N/A" duration yields zero, which uploads.Validate rejects as
// unreadable rather than accepting an empty recording.
func ParseProbe(data []byte) (uploads.Probe, error) {
var j probeJSON
if err := json.Unmarshal(data, &j); err != nil {
return uploads.Probe{}, fmt.Errorf("decode: %w", err)
}
var p uploads.Probe
for _, s := range j.Streams {
switch s.CodecType {
case "audio":
p.HasAudio = true
case "video":
if s.Disposition.AttachedPic == 0 {
p.HasVideo = true
}
}
}
if secs, err := strconv.ParseFloat(j.Format.Duration, 64); err == nil && secs > 0 {
p.Duration = time.Duration(secs * float64(time.Second))
}
return p, nil
}
+186
View File
@@ -0,0 +1,186 @@
package audio_test
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/adapters/audio"
"git.d-ma.be/mathias/tapir/internal/uploads"
)
func TestParseProbe(t *testing.T) {
tests := []struct {
name string
json string
want uploads.Probe
}{
{
name: "audio-only m4a",
json: `{"streams":[{"codec_type":"audio","disposition":{"attached_pic":0}}],"format":{"duration":"2701.5"}}`,
want: uploads.Probe{Duration: 2701*time.Second + 500*time.Millisecond, HasAudio: true},
},
{
name: "mp4 with a real video stream",
json: `{"streams":[{"codec_type":"video","disposition":{"attached_pic":0}},{"codec_type":"audio"}],"format":{"duration":"240.0"}}`,
want: uploads.Probe{Duration: 240 * time.Second, HasAudio: true, HasVideo: true},
},
{
name: "mp3 whose only video stream is attached cover art is NOT video",
json: `{"streams":[{"codec_type":"audio"},{"codec_type":"video","disposition":{"attached_pic":1}}],"format":{"duration":"60"}}`,
want: uploads.Probe{Duration: 60 * time.Second, HasAudio: true},
},
{
name: "no streams at all",
json: `{"streams":[],"format":{"duration":"10"}}`,
want: uploads.Probe{Duration: 10 * time.Second},
},
{
name: "duration absent means zero, which Validate rejects as unreadable",
json: `{"streams":[{"codec_type":"audio"}],"format":{}}`,
want: uploads.Probe{HasAudio: true},
},
{
name: "duration reported as N/A means zero",
json: `{"streams":[{"codec_type":"audio"}],"format":{"duration":"N/A"}}`,
want: uploads.Probe{HasAudio: true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := audio.ParseProbe([]byte(tt.json))
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestParseProbe_GarbageIsAnError(t *testing.T) {
_, err := audio.ParseProbe([]byte("not json"))
assert.Error(t, err)
}
// stub writes an executable shell script standing in for ffprobe. This is a fake
// at the process boundary: the real binary is not on every host (it is not on
// koala), and what is under test is how Probe drives and interprets a process.
func stub(t *testing.T, body string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "ffprobe")
require.NoError(t, os.WriteFile(p, []byte("#!/bin/sh\n"+body+"\n"), 0o755))
return p
}
func TestFFprobeProbe_ParsesWhatTheProcessPrints(t *testing.T) {
bin := stub(t, `echo '{"streams":[{"codec_type":"audio"}],"format":{"duration":"90"}}'`)
got, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/ignored")
require.NoError(t, err)
assert.Equal(t, uploads.Probe{Duration: 90 * time.Second, HasAudio: true}, got)
}
func TestFFprobeProbe_PassesTheFilePathAsTheLastArgument(t *testing.T) {
out := filepath.Join(t.TempDir(), "args")
bin := stub(t, `for a; do last="$a"; done; printf '%s' "$last" > `+out+`
echo '{"streams":[{"codec_type":"audio"}],"format":{"duration":"1"}}'`)
_, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/spool/upload-123")
require.NoError(t, err)
got, err := os.ReadFile(out)
require.NoError(t, err)
assert.Equal(t, "/spool/upload-123", string(got))
}
func TestFFprobeProbe_NonzeroExitMeansTheFileIsUnreadable(t *testing.T) {
bin := stub(t, `echo "Invalid data found when processing input" >&2; exit 1`)
_, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/x")
assert.ErrorIs(t, err, uploads.ErrUnreadable)
}
func TestFFprobeProbe_MissingBinaryIsOurFaultNotAnUnreadableFile(t *testing.T) {
_, err := (&audio.FFprobe{Bin: filepath.Join(t.TempDir(), "no-such-ffprobe")}).Probe(context.Background(), "/x")
require.Error(t, err)
assert.False(t, errors.Is(err, uploads.ErrUnreadable),
"a missing ffprobe must not be reported to the user as their file being bad")
}
func TestFFprobeProbe_StopsWhenTheContextIsCancelled(t *testing.T) {
bin := stub(t, `sleep 30`)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
start := time.Now()
_, err := (&audio.FFprobe{Bin: bin}).Probe(ctx, "/x")
require.Error(t, err)
assert.Less(t, time.Since(start), 5*time.Second)
assert.False(t, errors.Is(err, uploads.ErrUnreadable), "a timeout is not a verdict on the file")
}
// argvStub records the arguments it was called with into a file and then behaves as
// a successful ffprobe, so a test can assert exactly what the process was asked.
func argvStub(t *testing.T) (bin, argvFile string) {
t.Helper()
argvFile = filepath.Join(t.TempDir(), "argv")
bin = stub(t, `printf '%s\n' "$@" > `+argvFile+`
echo '{"streams":[{"codec_type":"audio"}],"format":{"duration":"1"}}'`)
return bin, argvFile
}
func argvOf(t *testing.T, file string) []string {
t.Helper()
raw, err := os.ReadFile(file)
require.NoError(t, err)
return strings.Split(strings.TrimSpace(string(raw)), "\n")
}
func indexOf(args []string, want string) int {
for i, a := range args {
if a == want {
return i
}
}
return -1
}
func TestFFprobeProbe_RestrictsWhatTheFileMayMakeFFmpegOpen(t *testing.T) {
// The input is attacker-supplied. ffmpeg can be told by a file's CONTENT to open
// other files or URLs (HLS and concat playlists); recent versions refuse by
// default, but that is version luck. Pin it: local files and pipes only, and
// only the demuxers real audio recordings use.
bin, argvFile := argvStub(t)
_, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/spool/upload-1")
require.NoError(t, err)
args := argvOf(t, argvFile)
pw := indexOf(args, "-protocol_whitelist")
require.GreaterOrEqual(t, pw, 0, "argv: %v", args)
assert.Equal(t, "file,pipe", args[pw+1])
fw := indexOf(args, "-format_whitelist")
require.GreaterOrEqual(t, fw, 0, "argv: %v", args)
assert.Equal(t, audio.AllowedDemuxers, args[fw+1])
assert.Equal(t, "/spool/upload-1", args[len(args)-1], "the path stays the last argument")
}
func TestAllowedDemuxers_ExcludeThePlaylistFormats(t *testing.T) {
for _, bad := range []string{"concat", "hls", "applehttp", "dash", "rtsp", "sdp", "image2"} {
assert.NotContains(t, strings.Split(audio.AllowedDemuxers, ","), bad,
"%s can make ffmpeg open other files or URLs named inside the upload", bad)
}
for _, good := range []string{"wav", "mov", "mp4", "m4a", "mp3", "ogg", "matroska", "flac"} {
assert.Contains(t, strings.Split(audio.AllowedDemuxers, ","), good)
}
}
@@ -0,0 +1,32 @@
package audio_test
import (
"context"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/adapters/audio"
"git.d-ma.be/mathias/tapir/internal/uploads"
)
// TestFFprobeLive_HostilePlaylistsAreRefusedAndRealAudioIsNot runs the REAL ffprobe
// (opt-in: TAPIR_LIVE_FFPROBE) on crafted concat/HLS files and on a real m4a.
func TestFFprobeLive_HostilePlaylistsAreRefusedAndRealAudioIsNot(t *testing.T) {
bin := os.Getenv("TAPIR_LIVE_FFPROBE")
if bin == "" {
t.Skip("set TAPIR_LIVE_FFPROBE (and TAPIR_LIVE_HOSTILE_DIR, TAPIR_LIVE_M4A) to run against the real ffprobe")
}
p := &audio.FFprobe{Bin: bin}
for _, name := range []string{"up-concat", "up-hls"} {
_, err := p.Probe(context.Background(), os.Getenv("TAPIR_LIVE_HOSTILE_DIR")+"/"+name)
assert.ErrorIs(t, err, uploads.ErrUnreadable, name)
}
got, err := p.Probe(context.Background(), os.Getenv("TAPIR_LIVE_M4A"))
require.NoError(t, err)
assert.True(t, got.HasAudio)
assert.False(t, got.HasVideo)
assert.InDelta(t, 82.5, got.Duration.Seconds(), 1)
}
+10 -1
View File
@@ -41,6 +41,11 @@ type Request struct {
Transcript string
History []Turn
Question string
// MaxChars, when positive, overrides the Service's transcript budget for this
// request. The Service default is sized for YouTube captions (~22 minutes of
// speech); an uploaded recording is much longer, and truncation keeps only the
// head (ADR-033). Zero or negative means "use the Service default".
MaxChars int
}
// Reply is the model's answer plus whether the transcript was bounded to fit the
@@ -113,7 +118,11 @@ func (s *Service) Answer(ctx context.Context, req Request) (Reply, error) {
model = s.DefaultModel("")
}
transcript, truncated := truncate(req.Transcript, s.maxChars)
budget := s.maxChars
if req.MaxChars > 0 {
budget = req.MaxChars
}
transcript, truncated := truncate(req.Transcript, budget)
system := buildSystem(transcript, truncated)
user := buildUser(req.History, req.Question)
+52
View File
@@ -0,0 +1,52 @@
package chat
import (
"context"
"strings"
"testing"
)
// A Request may carry its own transcript budget. The Service's cap (18,000 chars by
// default) is sized for YouTube captions; an uploaded recording is much longer
// (~50,000 chars an hour), and keeping only the head of it would answer questions
// about the last two-thirds of a meeting from text that does not contain them
// (ADR-033). The per-request value overrides; zero means "the service default".
func TestAnswerMaxCharsOverridesTheServiceCap(t *testing.T) {
tests := []struct {
name string
serviceCap int
requestCap int
transcriptLen int
wantKept int
wantTruncated bool
}{
{"request cap above the service cap keeps the whole transcript", 10, 100, 50, 50, false},
{"request cap below the service cap bounds tighter", 100, 10, 50, 10, true},
{"zero request cap falls back to the service cap", 20, 0, 50, 20, true},
{"negative request cap is treated as unset", 20, -1, 50, 20, true},
{"request cap exceeded is reported as truncated", 10, 30, 50, 30, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := &factory{answer: "answer"}
s := New(f.make, []string{"koala/phi4-mini"}, tt.serviceCap)
reply, err := s.Answer(context.Background(), Request{
Model: "koala/phi4-mini",
Transcript: strings.Repeat("x", tt.transcriptLen),
Question: "what was decided?",
MaxChars: tt.requestCap,
})
if err != nil {
t.Fatalf("Answer: %v", err)
}
if got := strings.Count(f.used.lastSystem, "x"); got != tt.wantKept {
t.Errorf("transcript chars sent = %d, want %d", got, tt.wantKept)
}
if reply.Truncated != tt.wantTruncated {
t.Errorf("Truncated = %v, want %v", reply.Truncated, tt.wantTruncated)
}
})
}
}
+36 -2
View File
@@ -25,12 +25,16 @@ import (
// docs/homelab-integration.md.
const defaultMaxTokens = 8192
// defaultTemperature is what every caller got before WithTemperature existed.
const defaultTemperature = 0.2
// Client calls an OpenAI-compatible chat completions endpoint.
type Client struct {
baseURL string
apiKey string
model string
maxTokens int
temp float64
httpClient *http.Client
usageHook func(model string, prompt, completion int)
}
@@ -51,6 +55,35 @@ func WithMaxTokens(n int) Option {
}
}
// maxErrorBody bounds how much of a failing response ends up in an error message.
const maxErrorBody = 512
// HTTPError is a non-200 answer from the gateway. Its text keeps the form callers that
// log it already rely on, with the body bounded; its Code is what code may branch on and
// what errkind turns into a kind. The body is provider text and can echo the prompt, so
// nothing private may persist or display Error() (ADR-035 review F3).
type HTTPError struct {
Code int
Body string
}
func (e *HTTPError) Error() string {
body := e.Body
if len(body) > maxErrorBody {
body = body[:maxErrorBody] + "…"
}
return fmt.Sprintf("LLM returned %d: %s", e.Code, body)
}
// HTTPStatus implements errkind.HTTPStatuser.
func (e *HTTPError) HTTPStatus() int { return e.Code }
// WithTemperature overrides the sampling temperature. Zero IS a valid value and is sent
// as zero: the reconciler's accuracy was measured at temperature 0 and must run there.
func WithTemperature(t float64) Option {
return func(c *Client) { c.temp = t }
}
// WithUsageHook registers a callback fired after a successful completion with the
// model and the prompt/completion token counts from the response usage block. It
// keeps this copied, stdlib-only package (ADR-004) decoupled from metrics: the
@@ -66,6 +99,7 @@ func New(baseURL, apiKey, model string, timeout time.Duration, opts ...Option) *
apiKey: apiKey,
model: model,
maxTokens: defaultMaxTokens,
temp: defaultTemperature,
httpClient: &http.Client{Timeout: timeout},
}
for _, opt := range opts {
@@ -105,7 +139,7 @@ func (c *Client) Complete(ctx context.Context, system, user string) (string, err
{Role: "system", Content: system},
{Role: "user", Content: user},
},
Temperature: 0.2,
Temperature: c.temp,
MaxTokens: c.maxTokens,
}
b, err := json.Marshal(body)
@@ -155,7 +189,7 @@ func (c *Client) Complete(ctx context.Context, system, user string) (string, err
return "", fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("LLM returned %d: %s", resp.StatusCode, out)
return "", &HTTPError{Code: resp.StatusCode, Body: string(out)}
}
var cr chatResponse
@@ -0,0 +1,63 @@
package llm
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.d-ma.be/mathias/tapir/internal/errkind"
)
// A non-200 from the gateway is an HTTP status error: its kind can be logged and stored
// without its body, which can echo the prompt (adversarial review F3).
func failingGateway(t *testing.T, status int, body string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return srv
}
func TestClient_ANon200IsAnHTTPStatusErrorWhoseKindCarriesNoBody(t *testing.T) {
srv := failingGateway(t, http.StatusBadRequest, `{"error":"prompt was: Anders ansvarar för virket"}`)
_, err := New(srv.URL, "", "m", 5*time.Second).Complete(context.Background(), "s", "u")
if err == nil {
t.Fatal("expected an error")
}
if got := errkind.Of(err); got != "http_400" {
t.Errorf("errkind.Of = %q, want http_400", got)
}
var he *HTTPError
if !errors.As(err, &he) || he.Code != http.StatusBadRequest {
t.Errorf("error is not an *HTTPError with the status code: %v", err)
}
}
func TestClient_TheErrorTextIsUnchangedForCallersThatLogIt(t *testing.T) {
srv := failingGateway(t, http.StatusBadGateway, "upstream down")
_, err := New(srv.URL, "", "m", 5*time.Second).Complete(context.Background(), "s", "u")
if err == nil || err.Error() != "LLM returned 502: upstream down" {
t.Errorf("error text = %v: callers that log it already rely on this form", err)
}
}
func TestClient_AnEnormousErrorBodyIsBoundedInTheErrorText(t *testing.T) {
srv := failingGateway(t, http.StatusInternalServerError, strings.Repeat("x", 100_000))
_, err := New(srv.URL, "", "m", 5*time.Second).Complete(context.Background(), "s", "u")
if err == nil || len(err.Error()) > 1024 {
t.Errorf("error text is %d bytes: a gateway's whole body must not become an error message", len(err.Error()))
}
}
+57
View File
@@ -0,0 +1,57 @@
package llm
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// capturedTemperature runs one Complete against a fake gateway and returns the
// "temperature" field the request carried, and whether it was present at all.
func capturedTemperature(t *testing.T, opts ...Option) (float64, bool) {
t.Helper()
var body map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &body)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"choices": []map[string]any{{"message": map[string]any{"role": "assistant", "content": "ok"}}}})
}))
defer srv.Close()
if _, err := New(srv.URL, "", "m", 5*time.Second, opts...).Complete(context.Background(), "s", "u"); err != nil {
t.Fatalf("Complete: %v", err)
}
v, ok := body["temperature"].(float64)
return v, ok
}
func TestClient_DefaultTemperatureIsUnchanged(t *testing.T) {
got, ok := capturedTemperature(t)
if !ok || got != 0.2 {
t.Fatalf("default temperature = %v (present %v), want 0.2: existing callers must not change behaviour", got, ok)
}
}
func TestClient_WithTemperatureZeroSendsExactlyZero(t *testing.T) {
// The reconciler's accuracy (92%/97%) was measured at temperature 0. Zero must be
// SENT, not treated as "unset" and replaced by the default.
got, ok := capturedTemperature(t, WithTemperature(0))
if !ok || got != 0 {
t.Fatalf("temperature = %v (present %v), want an explicit 0", got, ok)
}
}
func TestClient_WithTemperatureSendsTheGivenValue(t *testing.T) {
got, ok := capturedTemperature(t, WithTemperature(0.7))
if !ok || got != 0.7 {
t.Fatalf("temperature = %v (present %v), want 0.7", got, ok)
}
}
@@ -0,0 +1,3 @@
-- Drops the private uploaded-transcript store and every transcript in it. There
-- is no revert of the data: the original audio was never kept (ADR-033).
DROP TABLE uploaded_transcripts;
@@ -0,0 +1,48 @@
-- Migration 017: uploaded_transcripts — PRIVATE transcripts of audio a user
-- uploaded themselves (ADR-033, tapir#33).
--
-- This is deliberately NOT the shared `transcripts` table (migration 015,
-- ADR-021). That table is non-RLS on the explicit grounds that it holds only
-- public caption content and nothing user-identifying. A recorded meeting is the
-- opposite: private third-party content owned by exactly one user. Reusing
-- `transcripts` — or adding a user_id to it with a partial policy — would put a
-- private row in the one table the isolation test asserts is shared, and would
-- pass CI green. A separate table keeps ADR-021's exception singular.
--
-- Row-level security follows migration 003 exactly: ENABLE + FORCE (the app
-- connects as the table owner, which bypasses RLS unless forced), and a policy
-- keyed off the per-request GUC tapir.current_user_id. With no GUC set the
-- predicate is NULL and the table is deny-all.
--
-- The original audio is never stored (ADR-033): this row is all that is kept.
-- segments holds the timestamped cues; re-transcribing 90 minutes costs ~7
-- minutes, so discarding the timestamps would be irreversible.
CREATE TABLE uploaded_transcripts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued', 'transcribing', 'done', 'failed')),
content TEXT,
segments JSONB,
endpoint TEXT, -- which backend served it (iguana | berget); a quality fact
audio_seconds DOUBLE PRECISION,
error_kind TEXT, -- typed, never a provider's error string (ADR-033)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ,
CONSTRAINT uploaded_transcripts_done_has_content
CHECK (status <> 'done' OR content IS NOT NULL),
CONSTRAINT uploaded_transcripts_failed_has_error_kind
CHECK (status <> 'failed' OR error_kind IS NOT NULL)
);
CREATE INDEX idx_uploaded_transcripts_user ON uploaded_transcripts (user_id, created_at DESC);
ALTER TABLE uploaded_transcripts ENABLE ROW LEVEL SECURITY;
ALTER TABLE uploaded_transcripts FORCE ROW LEVEL SECURITY;
CREATE POLICY uploaded_transcripts_isolation ON uploaded_transcripts
FOR ALL
USING (user_id = current_setting('tapir.current_user_id', true)::uuid);
COMMENT ON TABLE uploaded_transcripts IS
'PRIVATE per-user transcripts of user-supplied audio. RLS-scoped (ADR-012). Never merge into the shared transcripts table (ADR-021, ADR-033).';
@@ -0,0 +1 @@
ALTER TABLE uploaded_transcripts DROP COLUMN had_video;
@@ -0,0 +1,9 @@
-- Migration 018: record whether an uploaded file carried a video track (ADR-033,
-- amended 2026-10-01).
--
-- A Teams meeting with the camera off is still an .mp4 with a video track, so video
-- files are ACCEPTED: only the audio is extracted and the video is discarded. This
-- flag lets the transcript page say so — "this file contained a video track; only its
-- audio was used" — so the user is never left to wonder what happened to the picture.
-- It outlives the job: completing or failing an upload does not clear it.
ALTER TABLE uploaded_transcripts ADD COLUMN had_video BOOLEAN NOT NULL DEFAULT FALSE;
@@ -0,0 +1 @@
ALTER TABLE uploaded_transcripts DROP COLUMN reconciliation;
@@ -0,0 +1,12 @@
-- Migration 019: how a merged transcript came about (ADR-034).
--
-- When two engines transcribed the same audio and a local model chose between their
-- disagreements, this records the status, the exact counts, and a bounded list of the
-- contested spots (changed or unsure, each with a position and both readings) so the
-- page can say what was done and the user can check the uncertain spots against the
-- audio. NULL means a single engine: no second opinion, and the page must not claim one.
--
-- It lives on the already-FORCE'd, RLS-scoped uploaded_transcripts, so the exact-set
-- isolation test is unaffected. The contested spots hold fragments of the private
-- transcript, which is why they are here and nowhere else.
ALTER TABLE uploaded_transcripts ADD COLUMN reconciliation JSONB;
@@ -0,0 +1,4 @@
ALTER TABLE uploaded_transcripts
DROP COLUMN alive_at,
DROP COLUMN progress_at,
DROP COLUMN progress;
@@ -0,0 +1,15 @@
-- Migration 020: where an upload job is, and whether the service is still reporting (ADR-035).
--
-- progress the job's ordered phases as JSON (key, label, state, times, done/total).
-- progress_at when a phase last REALLY changed or advanced.
-- alive_at a heartbeat stamped every few seconds by a goroutine independent of any
-- engine call. The two differ on purpose: alive says the service is still
-- reporting, progress says work is moving, and a page that cannot tell a
-- crashed service from a slow model is the failure this exists to end.
--
-- All three are NULL until the job starts. They sit on the already-FORCE'd, RLS-scoped
-- uploaded_transcripts, so the exact-set isolation test is unaffected.
ALTER TABLE uploaded_transcripts
ADD COLUMN progress JSONB,
ADD COLUMN progress_at TIMESTAMPTZ,
ADD COLUMN alive_at TIMESTAMPTZ;
@@ -0,0 +1,249 @@
package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// ErrUploadNotFound is returned when an uploaded transcript does not exist OR is
// not the caller's. The two are deliberately indistinguishable: a non-owner learns
// nothing about whether someone else's upload exists (ADR-033).
var ErrUploadNotFound = errors.New("store: uploaded transcript not found")
// Uploaded transcripts are PRIVATE per-user content, stored in their own
// RLS-scoped table (migration 017) — never in the shared `transcripts` (ADR-021).
// Every method goes through withUser, so row-level security confines it to the
// caller's rows; each statement ALSO names user_id in its predicate, so the
// isolation does not rest on a single mechanism and stays testable under the
// superuser connection the unit tests use.
// CreateUpload records a new upload in the queued state and returns its id.
// hadVideo records that the file carried a video track (only its audio is used);
// it is kept for the life of the row so the page can say so.
func (s *Store) CreateUpload(ctx context.Context, userID, title string, hadVideo bool) (string, error) {
var id string
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx,
`INSERT INTO uploaded_transcripts (user_id, title, had_video) VALUES ($1, $2, $3) RETURNING id`,
userID, title, hadVideo).Scan(&id)
}); err != nil {
return "", fmt.Errorf("store: create upload: %w", err)
}
return id, nil
}
// StartTranscribing moves an upload to transcribing.
func (s *Store) StartTranscribing(ctx context.Context, userID, id string) error {
return s.updateUpload(ctx, userID, id, "start transcribing",
`UPDATE uploaded_transcripts SET status = 'transcribing'
WHERE id = $1 AND user_id = $2`)
}
// CompleteUpload stores the finished transcription and moves the upload to done.
func (s *Store) CompleteUpload(ctx context.Context, userID, id string, t domain.Transcription) error {
segs, err := json.Marshal(t.Segments)
if err != nil {
return fmt.Errorf("store: complete upload: marshal segments: %w", err)
}
// A nil Reconciliation must be SQL NULL, not the JSON text "null": the page decides
// whether to claim a second opinion by whether this column is NULL.
var rec []byte
if t.Reconciliation != nil {
if rec, err = json.Marshal(t.Reconciliation); err != nil {
return fmt.Errorf("store: complete upload: marshal reconciliation: %w", err)
}
}
return s.updateUpload(ctx, userID, id, "complete upload",
`UPDATE uploaded_transcripts
SET status = 'done', content = $3, segments = $4, endpoint = $5,
audio_seconds = $6, reconciliation = $7, error_kind = NULL, finished_at = NOW()
WHERE id = $1 AND user_id = $2`,
t.Text, segs, t.Endpoint, t.AudioSeconds, rec)
}
// SaveProgress records the job's phases and when something last really changed
// (ADR-035). It replaces the previous snapshot: the Tracker holds the whole plan.
func (s *Store) SaveProgress(ctx context.Context, userID, id string, steps []domain.ProgressStep, at time.Time) error {
b, err := json.Marshal(steps)
if err != nil {
return fmt.Errorf("store: save progress: marshal: %w", err)
}
return s.updateUpload(ctx, userID, id, "save progress",
`UPDATE uploaded_transcripts SET progress = $3, progress_at = $4
WHERE id = $1 AND user_id = $2`, b, at)
}
// Heartbeat stamps the upload as alive now. It does not touch progress_at: alive says
// the service is still reporting, not that work is moving (ADR-035).
func (s *Store) Heartbeat(ctx context.Context, userID, id string) error {
return s.updateUpload(ctx, userID, id, "heartbeat",
`UPDATE uploaded_transcripts SET alive_at = NOW()
WHERE id = $1 AND user_id = $2`)
}
// FailUpload moves an upload to failed with a typed reason.
func (s *Store) FailUpload(ctx context.Context, userID, id string, kind domain.UploadErrorKind) error {
return s.updateUpload(ctx, userID, id, "fail upload",
`UPDATE uploaded_transcripts
SET status = 'failed', error_kind = $3, finished_at = NOW()
WHERE id = $1 AND user_id = $2`,
string(kind))
}
// updateUpload runs one UPDATE keyed on ($1 = id, $2 = userID) plus extra args,
// and maps "no row touched" to ErrUploadNotFound — which is what a write to another
// user's upload looks like under RLS (the row is invisible, not forbidden).
func (s *Store) updateUpload(ctx context.Context, userID, id, op, sql string, args ...any) error {
all := append([]any{id, userID}, args...)
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
ct, err := tx.Exec(ctx, sql, all...)
if err != nil {
return fmt.Errorf("store: %s: %w", op, err)
}
if ct.RowsAffected() == 0 {
return ErrUploadNotFound
}
return nil
})
}
// GetUpload returns one of the caller's uploads, including the transcript text.
func (s *Store) GetUpload(ctx context.Context, userID, id string) (*domain.UploadedTranscript, error) {
var u domain.UploadedTranscript
err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
row := tx.QueryRow(ctx, uploadSelect+` WHERE id = $1 AND user_id = $2`, id, userID)
return scanUpload(row, &u, true)
})
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUploadNotFound
}
if err != nil {
return nil, fmt.Errorf("store: get upload: %w", err)
}
return &u, nil
}
// ListUploads returns the caller's uploads, newest first, WITHOUT transcript text
// or segments: a list view must not drag every transcript along.
func (s *Store) ListUploads(ctx context.Context, userID string) ([]domain.UploadedTranscript, error) {
var out []domain.UploadedTranscript
err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, uploadSelect+` WHERE user_id = $1 ORDER BY created_at DESC, id`, userID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var u domain.UploadedTranscript
if err := scanUpload(rows, &u, false); err != nil {
return err
}
out = append(out, u)
}
return rows.Err()
})
if err != nil {
return nil, fmt.Errorf("store: list uploads: %w", err)
}
return out, nil
}
// FailInterruptedUploads is the start-up sweep. The audio of an upload is never
// retained (ADR-033), so a job that was queued or transcribing when the process
// stopped can never resume; leaving it would be a stuck spinner. Every such row,
// for every user, becomes failed/interrupted and the user re-uploads. It
// enumerates users from the un-RLS'd identity map and scopes each pass with
// withUser, exactly as scheduled discovery does (ADR-018). Returns rows failed.
func (s *Store) FailInterruptedUploads(ctx context.Context) (int, error) {
users, err := s.ListAllUsers(ctx)
if err != nil {
return 0, fmt.Errorf("store: fail interrupted uploads: %w", err)
}
total := 0
for _, u := range users {
err := s.withUser(ctx, u.UserID, func(tx pgx.Tx) error {
ct, err := tx.Exec(ctx,
`UPDATE uploaded_transcripts
SET status = 'failed', error_kind = $2, finished_at = NOW()
WHERE user_id = $1 AND status IN ('queued', 'transcribing')`,
u.UserID, string(domain.UploadErrInterrupted))
total += int(ct.RowsAffected())
return err
})
if err != nil {
return total, fmt.Errorf("store: fail interrupted uploads: user %s: %w", u.UserID, err)
}
}
return total, nil
}
const uploadSelect = `SELECT id, user_id, title, status, content, segments, endpoint,
audio_seconds, error_kind, had_video, reconciliation, progress, progress_at, alive_at,
created_at, finished_at FROM uploaded_transcripts`
// scanUpload scans one uploadSelect row. withText controls whether the transcript
// body and segments are kept (GetUpload) or dropped (ListUploads).
func scanUpload(row pgx.Row, u *domain.UploadedTranscript, withText bool) error {
var (
status, errKind, endpoint, content *string
segments, reconciliation, progress []byte
seconds *float64
finished, progressAt, aliveAt *time.Time
)
if err := row.Scan(&u.ID, &u.UserID, &u.Title, &status, &content, &segments, &endpoint,
&seconds, &errKind, &u.HadVideo, &reconciliation, &progress, &progressAt, &aliveAt,
&u.CreatedAt, &finished); err != nil {
return err
}
u.Status = domain.UploadStatus(deref(status))
u.Endpoint = deref(endpoint)
u.ErrorKind = domain.UploadErrorKind(deref(errKind))
if seconds != nil {
u.AudioSeconds = *seconds
}
if finished != nil {
u.FinishedAt = *finished
}
if !withText {
return nil
}
if len(progress) > 0 {
if err := json.Unmarshal(progress, &u.Progress); err != nil {
return fmt.Errorf("unmarshal progress: %w", err)
}
}
if progressAt != nil {
u.ProgressAt = *progressAt
}
if aliveAt != nil {
u.AliveAt = *aliveAt
}
if len(reconciliation) > 0 {
var r domain.Reconciliation
if err := json.Unmarshal(reconciliation, &r); err != nil {
return fmt.Errorf("unmarshal reconciliation: %w", err)
}
u.Reconciliation = &r
}
u.Content = deref(content)
if len(segments) > 0 {
if err := json.Unmarshal(segments, &u.Segments); err != nil {
return fmt.Errorf("unmarshal segments: %w", err)
}
}
return nil
}
func deref(s *string) string {
if s == nil {
return ""
}
return *s
}
@@ -0,0 +1,196 @@
package store_test
import (
"context"
"sort"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
)
// Isolation proof for ADR-033: an uploaded transcript is PRIVATE content owned by
// one user, so it lives in its own RLS-scoped table — never in the shared,
// non-RLS `transcripts` (ADR-021). These tests drive the database directly (raw
// SQL as the non-superuser `app` role) so they observe row-level security itself,
// not any application-layer WHERE clause. The superuser pool is used only to seed
// and to read pg_catalog; it bypasses RLS and so must never be the pool whose
// answers the assertions rest on.
// seedUploadedTranscript inserts one finished upload for userID as the superuser
// (RLS bypassed so both users' rows land regardless of any GUC) and returns its id.
// The text is synthetic — this repo is public, and a real transcript must never
// appear in it (#33).
func seedUploadedTranscript(t *testing.T, super *pgxpool.Pool, userID, text string) string {
t.Helper()
ctx := context.Background()
_, err := super.Exec(ctx, `INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING`, userID)
require.NoError(t, err)
var id string
require.NoError(t, super.QueryRow(ctx,
`INSERT INTO uploaded_transcripts (user_id, title, status, content)
VALUES ($1, 'synthetic fixture', 'done', $2) RETURNING id`,
userID, text).Scan(&id))
return id
}
func TestUploadedTranscriptsRLS_ScopedReadsSeeOnlyOwnRows(t *testing.T) {
newStore(t)
super := rawPool(t)
resetDB(t, super)
seedUploadedTranscript(t, super, userA, "text owned by A")
seedUploadedTranscript(t, super, userB, "text owned by B")
app := appPool(t, super)
require.Equal(t, 0, scopedCount(t, app, "", "uploaded_transcripts"),
"no tapir.current_user_id must be deny-all: this proves RLS is live, not bypassed")
require.Equal(t, 1, scopedCount(t, app, userA, "uploaded_transcripts"),
"user A must see exactly its own upload")
require.Equal(t, 1, scopedCount(t, app, userB, "uploaded_transcripts"),
"user B must see exactly its own upload")
// Count alone cannot distinguish "A's row" from "B's row" — read the content.
var got string
ctx := context.Background()
tx, err := app.Begin(ctx)
require.NoError(t, err)
defer tx.Rollback(ctx) //nolint:errcheck
_, err = tx.Exec(ctx, `SELECT set_config('tapir.current_user_id', $1, true)`, userA)
require.NoError(t, err)
require.NoError(t, tx.QueryRow(ctx, `SELECT content FROM uploaded_transcripts`).Scan(&got))
require.Equal(t, "text owned by A", got, "A's scoped read must return A's text, never B's")
}
func TestUploadedTranscriptsRLS_CrossUserWritesAffectZeroRows(t *testing.T) {
newStore(t)
super := rawPool(t)
resetDB(t, super)
seedUploadedTranscript(t, super, userA, "text owned by A")
bID := seedUploadedTranscript(t, super, userB, "text owned by B")
app := appPool(t, super)
require.Equal(t, int64(0), scopedRowsAffected(t, app, userA,
`UPDATE uploaded_transcripts SET content = 'hacked' WHERE id = $1`, bID),
"user A scoped UPDATE must touch zero of B's uploads")
require.Equal(t, int64(0), scopedRowsAffected(t, app, userA,
`DELETE FROM uploaded_transcripts WHERE id = $1`, bID),
"user A scoped DELETE must touch zero of B's uploads")
var content string
require.NoError(t, super.QueryRow(context.Background(),
`SELECT content FROM uploaded_transcripts WHERE id = $1`, bID).Scan(&content))
require.Equal(t, "text owned by B", content, "B's upload must survive A's writes unchanged")
}
func TestUploadedTranscriptsRLS_InsertForAnotherUserIsRejected(t *testing.T) {
newStore(t)
super := rawPool(t)
resetDB(t, super)
seedUploadedTranscript(t, super, userA, "text owned by A")
_, err := super.Exec(context.Background(), `INSERT INTO users (id) VALUES ($1)`, userB)
require.NoError(t, err)
app := appPool(t, super)
ctx := context.Background()
tx, err := app.Begin(ctx)
require.NoError(t, err)
defer tx.Rollback(ctx) //nolint:errcheck
_, err = tx.Exec(ctx, `SELECT set_config('tapir.current_user_id', $1, true)`, userA)
require.NoError(t, err)
// Scoped as A, plant a row that claims to belong to B. The policy's WITH CHECK
// (defaulting to USING under FOR ALL) must refuse it.
_, err = tx.Exec(ctx,
`INSERT INTO uploaded_transcripts (user_id, title, status, content)
VALUES ($1, 'planted', 'done', 'planted')`, userB)
require.Error(t, err, "a row scoped as A must not be insertable on behalf of B")
require.Contains(t, err.Error(), "row-level security")
}
func TestUploadedTranscripts_RowSecurityIsEnabledForcedAndKeyedByUser(t *testing.T) {
newStore(t)
super := rawPool(t)
ctx := context.Background()
var enabled, forced bool
require.NoError(t, super.QueryRow(ctx,
`SELECT relrowsecurity, relforcerowsecurity FROM pg_class
WHERE oid = 'public.uploaded_transcripts'::regclass`).Scan(&enabled, &forced))
require.True(t, enabled, "uploaded_transcripts must have row-level security enabled")
require.True(t, forced,
"FORCE is what binds the table-owner role tapir; without it the policy is dead in production")
var userIDNotNull bool
require.NoError(t, super.QueryRow(ctx,
`SELECT is_nullable = 'NO' FROM information_schema.columns
WHERE table_name = 'uploaded_transcripts' AND column_name = 'user_id'`).Scan(&userIDNotNull))
require.True(t, userIDNotNull, "an upload with no owner would match no policy and be unreachable")
}
func TestUploadedTranscripts_DeleteUserRemovesTheirUploads(t *testing.T) {
s := newStore(t)
super := rawPool(t)
resetDB(t, super)
seedUploadedTranscript(t, super, userA, "text owned by A")
bID := seedUploadedTranscript(t, super, userB, "text owned by B")
require.NoError(t, s.DeleteUser(context.Background(), userA))
var aRows, bRows int
ctx := context.Background()
require.NoError(t, super.QueryRow(ctx,
`SELECT count(*) FROM uploaded_transcripts WHERE user_id = $1`, userA).Scan(&aRows))
require.NoError(t, super.QueryRow(ctx,
`SELECT count(*) FROM uploaded_transcripts WHERE id = $1`, bID).Scan(&bRows))
require.Equal(t, 0, aRows, "deleting a user must remove their private transcripts (ON DELETE CASCADE)")
require.Equal(t, 1, bRows, "deleting A must not touch B's transcripts")
}
// rowSecurityOffTables are the public-schema tables that deliberately have row
// security OFF. The list is exact on purpose: a NEW table added without RLS must
// fail this test and force a decision, instead of passing CI by omission — the
// failure mode ADR-033 names, where a naive reuse "leaks private content across
// tenants and passes CI green".
//
// - transcripts: shared public caption content, the single exception for CONTENT
// (ADR-021). It is the one that must never gain a private row.
// - user_identities: the Dex-subject -> user_id map, read at login before any
// user is known (migration 004). Holds no user content.
// - invitations: exists before the user does, so there is no user to scope by;
// the single-use token is the capability (migration 009).
// - schema_migrations: golang-migrate bookkeeping.
//
// #33 describes `transcripts` as "the only non-RLS table". That is true of content
// and false of tables; this test asserts the real set.
var rowSecurityOffTables = []string{
"invitations",
"schema_migrations",
"transcripts",
"user_identities",
}
func TestRowSecurityOffTablesAreExactlyTheKnownSet(t *testing.T) {
newStore(t)
super := rawPool(t)
rows, err := super.Query(context.Background(),
`SELECT c.relname FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p') AND NOT c.relrowsecurity
ORDER BY c.relname`)
require.NoError(t, err)
defer rows.Close()
var got []string
for rows.Next() {
var name string
require.NoError(t, rows.Scan(&name))
got = append(got, name)
}
require.NoError(t, rows.Err())
want := append([]string{}, rowSecurityOffTables...)
sort.Strings(want)
require.Equal(t, want, got,
"every public table must have row security ON unless it is in rowSecurityOffTables; "+
"adding a table here is a decision that needs an ADR")
}
@@ -0,0 +1,137 @@
package store_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// Progress and heartbeat of an upload job (ADR-035, migration 020). Like the other
// upload store tests these run as the superuser, so the cross-user cases prove the
// statement's own user_id predicate; the database-enforced isolation is covered by the
// RLS tests, and migration 020 adds columns to the already-FORCE'd table, not a table.
func startedUpload(t *testing.T, s *store.Store) string {
t.Helper()
seedUsers(t, userA, userB)
id, err := s.CreateUpload(context.Background(), userA, "m", false)
require.NoError(t, err)
return id
}
func TestUploadProgress_AFreshUploadHasNoProgressAndNoHeartbeat(t *testing.T) {
s := newStore(t)
id := startedUpload(t, s)
got, err := s.GetUpload(context.Background(), userA, id)
require.NoError(t, err)
assert.Empty(t, got.Progress)
assert.True(t, got.ProgressAt.IsZero())
assert.True(t, got.AliveAt.IsZero())
}
func TestUploadProgress_StepsAndTheirTimesRoundTripInOrder(t *testing.T) {
s := newStore(t)
id := startedUpload(t, s)
ctx := context.Background()
at := time.Date(2026, 10, 2, 8, 30, 0, 0, time.UTC)
steps := []domain.ProgressStep{
{Key: "audio", Label: "Preparing audio", State: domain.StepDone, StartedAt: at, FinishedAt: at.Add(40 * time.Second)},
{Key: "decide", Label: "Deciding", State: domain.StepRunning, StartedAt: at.Add(time.Minute), Done: 12, Total: 282},
{Key: "save", Label: "Saving", State: domain.StepPending},
}
require.NoError(t, s.SaveProgress(ctx, userA, id, steps, at.Add(2*time.Minute)))
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
require.Len(t, got.Progress, 3)
assert.Equal(t, []string{"audio", "decide", "save"}, []string{got.Progress[0].Key, got.Progress[1].Key, got.Progress[2].Key})
assert.Equal(t, domain.StepRunning, got.Progress[1].State)
assert.Equal(t, 12, got.Progress[1].Done)
assert.Equal(t, 282, got.Progress[1].Total)
assert.True(t, got.Progress[0].FinishedAt.Equal(at.Add(40*time.Second)), "a step's finish time must survive the round trip")
assert.True(t, got.Progress[2].StartedAt.IsZero(), "a pending step has no start time, not a zero date")
assert.True(t, got.ProgressAt.Equal(at.Add(2*time.Minute)))
}
func TestUploadProgress_AHeartbeatMovesAliveAtAndNotProgressAt(t *testing.T) {
s := newStore(t)
id := startedUpload(t, s)
ctx := context.Background()
at := time.Date(2026, 10, 2, 8, 30, 0, 0, time.UTC)
require.NoError(t, s.SaveProgress(ctx, userA, id, []domain.ProgressStep{{Key: "audio", Label: "a", State: domain.StepRunning}}, at))
before := time.Now().Add(-time.Second)
require.NoError(t, s.Heartbeat(ctx, userA, id))
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
assert.True(t, got.AliveAt.After(before), "the heartbeat must stamp the row with now")
assert.True(t, got.ProgressAt.Equal(at), "alive is not progress: a heartbeat must not move progress_at")
}
func TestUploadProgress_ASecondSaveReplacesTheFirst(t *testing.T) {
s := newStore(t)
id := startedUpload(t, s)
ctx := context.Background()
at := time.Date(2026, 10, 2, 8, 30, 0, 0, time.UTC)
require.NoError(t, s.SaveProgress(ctx, userA, id, []domain.ProgressStep{{Key: "a", Label: "A", State: domain.StepRunning}}, at))
require.NoError(t, s.SaveProgress(ctx, userA, id, []domain.ProgressStep{{Key: "a", Label: "A", State: domain.StepDone}, {Key: "b", Label: "B", State: domain.StepRunning}}, at.Add(time.Minute)))
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
require.Len(t, got.Progress, 2)
assert.Equal(t, domain.StepDone, got.Progress[0].State)
}
func TestUploadProgress_ByAnotherUserChangesNothing(t *testing.T) {
s := newStore(t)
id := startedUpload(t, s)
ctx := context.Background()
errSave := s.SaveProgress(ctx, userB, id, []domain.ProgressStep{{Key: "x", Label: "X", State: domain.StepDone}}, time.Now())
errBeat := s.Heartbeat(ctx, userB, id)
assert.ErrorIs(t, errSave, store.ErrUploadNotFound)
assert.ErrorIs(t, errBeat, store.ErrUploadNotFound)
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
assert.Empty(t, got.Progress, "B's write must not have reached A's upload")
assert.True(t, got.AliveAt.IsZero())
}
func TestUploadProgress_ListDoesNotDragItAlong(t *testing.T) {
s := newStore(t)
id := startedUpload(t, s)
ctx := context.Background()
require.NoError(t, s.SaveProgress(ctx, userA, id, []domain.ProgressStep{{Key: "a", Label: "A", State: domain.StepRunning}}, time.Now()))
rows, err := s.ListUploads(ctx, userA)
require.NoError(t, err)
require.Len(t, rows, 1)
assert.Empty(t, rows[0].Progress, "the list view needs a status, not every row's phases")
}
func TestUploadProgress_SurvivesCompletion(t *testing.T) {
s := newStore(t)
id := startedUpload(t, s)
ctx := context.Background()
require.NoError(t, s.StartTranscribing(ctx, userA, id))
require.NoError(t, s.SaveProgress(ctx, userA, id, []domain.ProgressStep{{Key: "a", Label: "A", State: domain.StepDone}}, time.Now()))
require.NoError(t, s.CompleteUpload(ctx, userA, id, domain.Transcription{Text: "x", Endpoint: "e"}))
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
require.Len(t, got.Progress, 1, "a finished upload keeps its record of what was done, and how long each step took")
}
@@ -0,0 +1,286 @@
package store_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// These exercise the Store API over uploaded_transcripts. The test Store connects
// as the superuser, which BYPASSES row-level security — so the cross-user cases
// here prove the query's own user_id predicate (defence in depth), while the
// database-enforced guarantee is proven separately, as the non-superuser app
// role, in uploaded_transcripts_isolation_test.go. Text is synthetic; this repo is
// public and no real transcript may appear in it (#33).
func seedUsers(t *testing.T, ids ...string) {
t.Helper()
super := rawPool(t)
resetDB(t, super)
for _, id := range ids {
_, err := super.Exec(context.Background(), `INSERT INTO users (id) VALUES ($1)`, id)
require.NoError(t, err)
}
}
func TestCreateUpload_StartsQueuedAndIsOwnedByTheCreator(t *testing.T) {
s := newStore(t)
seedUsers(t, userA)
ctx := context.Background()
id, err := s.CreateUpload(ctx, userA, "Teams export", false)
require.NoError(t, err)
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
assert.Equal(t, id, got.ID)
assert.Equal(t, userA, got.UserID)
assert.Equal(t, "Teams export", got.Title)
assert.Equal(t, domain.UploadQueued, got.Status)
assert.Empty(t, got.Content)
assert.True(t, got.FinishedAt.IsZero(), "an unfinished upload has no finish time")
}
func TestUploadLifecycle_CompletedKeepsTextSegmentsEndpointAndDuration(t *testing.T) {
s := newStore(t)
seedUsers(t, userA)
ctx := context.Background()
id, err := s.CreateUpload(ctx, userA, "m", false)
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, id))
mid, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
assert.Equal(t, domain.UploadTranscribing, mid.Status)
want := domain.Transcription{
Text: "God morgon allihopa. Vi går igenom läget.",
Segments: []domain.Segment{
{Start: 0.0, End: 1.5, Text: "God morgon allihopa."},
{Start: 1.5, End: 4.25, Text: "Vi går igenom läget."},
},
Endpoint: "iguana/kb-whisper",
AudioSeconds: 4.25,
}
require.NoError(t, s.CompleteUpload(ctx, userA, id, want))
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
assert.Equal(t, domain.UploadDone, got.Status)
assert.Equal(t, want.Text, got.Content)
assert.Equal(t, want.Segments, got.Segments, "segment times must survive the JSONB round trip")
assert.Equal(t, "iguana/kb-whisper", got.Endpoint, "the serving endpoint is a quality fact and is recorded per row")
assert.InDelta(t, 4.25, got.AudioSeconds, 1e-9)
assert.False(t, got.FinishedAt.IsZero())
assert.Empty(t, got.ErrorKind)
}
func TestUploadLifecycle_FailedRecordsTypedKindAndNoContent(t *testing.T) {
s := newStore(t)
seedUsers(t, userA)
ctx := context.Background()
id, err := s.CreateUpload(ctx, userA, "m", false)
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, id))
require.NoError(t, s.FailUpload(ctx, userA, id, domain.UploadErrTranscriptionFailed))
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
assert.Equal(t, domain.UploadFailed, got.Status)
assert.Equal(t, domain.UploadErrTranscriptionFailed, got.ErrorKind)
assert.Empty(t, got.Content)
assert.False(t, got.FinishedAt.IsZero())
}
func TestGetUpload_AnotherUsersIDIsNotFound(t *testing.T) {
s := newStore(t)
seedUsers(t, userA, userB)
ctx := context.Background()
id, err := s.CreateUpload(ctx, userA, "A's meeting", false)
require.NoError(t, err)
_, err = s.GetUpload(ctx, userB, id)
assert.ErrorIs(t, err, store.ErrUploadNotFound,
"a non-owner must get not-found, not forbidden: existence is not disclosed")
}
func TestUploadWrites_ByAnotherUserChangeNothing(t *testing.T) {
s := newStore(t)
seedUsers(t, userA, userB)
ctx := context.Background()
id, err := s.CreateUpload(ctx, userA, "A's meeting", false)
require.NoError(t, err)
assert.ErrorIs(t, s.StartTranscribing(ctx, userB, id), store.ErrUploadNotFound)
assert.ErrorIs(t, s.CompleteUpload(ctx, userB, id, domain.Transcription{Text: "planted"}), store.ErrUploadNotFound)
assert.ErrorIs(t, s.FailUpload(ctx, userB, id, domain.UploadErrTranscriptionFailed), store.ErrUploadNotFound)
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
assert.Equal(t, domain.UploadQueued, got.Status, "B's writes must leave A's upload exactly as it was")
assert.Empty(t, got.Content)
}
func TestListUploads_ReturnsOnlyTheCallersNewestFirstWithoutTranscriptText(t *testing.T) {
s := newStore(t)
seedUsers(t, userA, userB)
ctx := context.Background()
first, err := s.CreateUpload(ctx, userA, "first", false)
require.NoError(t, err)
second, err := s.CreateUpload(ctx, userA, "second", false)
require.NoError(t, err)
_, err = s.CreateUpload(ctx, userB, "B's", false)
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, first))
require.NoError(t, s.CompleteUpload(ctx, userA, first, domain.Transcription{Text: "long text body", Endpoint: "e"}))
got, err := s.ListUploads(ctx, userA)
require.NoError(t, err)
require.Len(t, got, 2, "A must see only A's uploads")
assert.Equal(t, second, got[0].ID, "newest first")
assert.Equal(t, first, got[1].ID)
assert.Empty(t, got[1].Content, "a list row must not drag the whole transcript along")
assert.Equal(t, domain.UploadDone, got[1].Status)
}
func TestFailInterruptedUploads_FailsOnlyUnfinishedRowsForEveryUser(t *testing.T) {
s := newStore(t)
seedUsers(t, userA, userB)
super := rawPool(t)
ctx := context.Background()
for subject, uid := range map[string]string{"sub-a": userA, "sub-b": userB} {
_, err := super.Exec(ctx, `INSERT INTO user_identities (dex_subject, user_id) VALUES ($1, $2)`, subject, uid)
require.NoError(t, err)
}
queued, err := s.CreateUpload(ctx, userA, "queued", false)
require.NoError(t, err)
running, err := s.CreateUpload(ctx, userB, "running", false)
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userB, running))
done, err := s.CreateUpload(ctx, userA, "done", false)
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, done))
require.NoError(t, s.CompleteUpload(ctx, userA, done, domain.Transcription{Text: "kept", Endpoint: "e"}))
n, err := s.FailInterruptedUploads(ctx)
require.NoError(t, err)
assert.Equal(t, 2, n, "the queued and the transcribing row, across both users")
for _, c := range []struct{ user, id string }{{userA, queued}, {userB, running}} {
got, err := s.GetUpload(ctx, c.user, c.id)
require.NoError(t, err)
assert.Equal(t, domain.UploadFailed, got.Status)
assert.Equal(t, domain.UploadErrInterrupted, got.ErrorKind,
"audio is not retained, so a restart cannot resume: the honest state is failed/interrupted")
}
kept, err := s.GetUpload(ctx, userA, done)
require.NoError(t, err)
assert.Equal(t, domain.UploadDone, kept.Status, "a finished transcript must survive the sweep")
assert.Equal(t, "kept", kept.Content)
}
func TestCreateUpload_RecordsWhetherTheFileHadAVideoTrack(t *testing.T) {
s := newStore(t)
seedUsers(t, userA)
ctx := context.Background()
withVideo, err := s.CreateUpload(ctx, userA, "camera off", true)
require.NoError(t, err)
audioOnly, err := s.CreateUpload(ctx, userA, "voice only", false)
require.NoError(t, err)
gotVideo, err := s.GetUpload(ctx, userA, withVideo)
require.NoError(t, err)
gotAudio, err := s.GetUpload(ctx, userA, audioOnly)
require.NoError(t, err)
listed, err := s.ListUploads(ctx, userA)
require.NoError(t, err)
assert.True(t, gotVideo.HadVideo, "the page must be able to say the video was ignored")
assert.False(t, gotAudio.HadVideo)
byID := map[string]bool{}
for _, u := range listed {
byID[u.ID] = u.HadVideo
}
assert.Equal(t, map[string]bool{withVideo: true, audioOnly: false}, byID, "the list carries it too")
}
func TestUploadHadVideo_SurvivesCompletionAndFailure(t *testing.T) {
s := newStore(t)
seedUsers(t, userA)
ctx := context.Background()
done, err := s.CreateUpload(ctx, userA, "d", true)
require.NoError(t, err)
failed, err := s.CreateUpload(ctx, userA, "f", true)
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, done))
require.NoError(t, s.CompleteUpload(ctx, userA, done, domain.Transcription{Text: "x", Endpoint: "e"}))
require.NoError(t, s.FailUpload(ctx, userA, failed, domain.UploadErrTranscriptionFailed))
for _, id := range []string{done, failed} {
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
assert.True(t, got.HadVideo, "completing or failing must not clear it: the notice outlives the job")
}
}
func TestUploadLifecycle_ReconciliationRoundTripsWithItsContestedSpots(t *testing.T) {
s := newStore(t)
seedUsers(t, userA)
ctx := context.Background()
id, err := s.CreateUpload(ctx, userA, "m", false)
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, id))
want := &domain.Reconciliation{
Status: domain.ReconcileDone, Base: "iguana/pianissimo", Alt: "iguana/kb-whisper", Reconciler: "iguana/gemma4-26b",
Disputes: 380, AutoResolved: 90, Adjudicated: 285, Changed: 120, Unsure: 5, Errors: 0,
Contested: []domain.ContestedSpot{
{Seconds: 2210.5, Base: "Axel", Alt: "Alex", Used: "alt"},
{Seconds: 3400, Base: "A+2", Alt: "en A", Used: "base", Unsure: true},
},
}
require.NoError(t, s.CompleteUpload(ctx, userA, id, domain.Transcription{Text: "x", Endpoint: "e", Reconciliation: want}))
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
assert.Equal(t, want, got.Reconciliation)
}
func TestUploadLifecycle_ASingleEngineTranscriptHasNoReconciliation(t *testing.T) {
s := newStore(t)
seedUsers(t, userA)
ctx := context.Background()
id, err := s.CreateUpload(ctx, userA, "m", false)
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, id))
require.NoError(t, s.CompleteUpload(ctx, userA, id, domain.Transcription{Text: "x", Endpoint: "e"}))
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
assert.Nil(t, got.Reconciliation, "no reconciliation is not an empty one: the page must not claim a second opinion")
}
func TestUploadLifecycle_ASkippedReconciliationKeepsItsReason(t *testing.T) {
s := newStore(t)
seedUsers(t, userA)
ctx := context.Background()
id, err := s.CreateUpload(ctx, userA, "m", false)
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, id))
rec := &domain.Reconciliation{Status: domain.ReconcileSkipped, Reason: "the model failed 3 times in a row: gateway down", Errors: 3}
require.NoError(t, s.CompleteUpload(ctx, userA, id, domain.Transcription{Text: "x", Endpoint: "e", Reconciliation: rec}))
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
assert.Equal(t, rec, got.Reconciliation)
}
+53
View File
@@ -0,0 +1,53 @@
package transcribe
import (
"context"
"errors"
"fmt"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// Backend is one transcription endpoint. *Gateway satisfies it; Name is the value
// recorded as the serving endpoint.
type Backend interface {
Name() string
Transcribe(ctx context.Context, wavPath string) (domain.Transcription, error)
}
// Chain tries a primary (local) backend and, if it fails for ANY reason, a
// fallback. The fallback exists because iguana can wedge permanently on a Metal
// OOM (infra#273) — which is also why a fallback is a data-egress decision: the
// berget endpoint is a cloud service, so the result records which one served it.
type Chain struct {
primary Backend
fallback Backend // nil disables the fallback
}
// NewChain builds a Chain. A nil fallback means a primary failure is the result.
func NewChain(primary, fallback Backend) *Chain {
return &Chain{primary: primary, fallback: fallback}
}
// Transcribe returns the first backend's success. It falls back on any primary
// error — transport, timeout, non-2xx, empty transcript — and never inspects a
// response body to decide. A cancelled context stops the chain: a job the user or
// shutdown cancelled must not ship its audio to a cloud endpoint as a last act.
func (c *Chain) Transcribe(ctx context.Context, wavPath string) (domain.Transcription, error) {
t, err := c.primary.Transcribe(ctx, wavPath)
if err == nil {
t.Endpoint = c.primary.Name()
return t, nil
}
if c.fallback == nil || ctx.Err() != nil {
return domain.Transcription{}, fmt.Errorf("%s: %w", c.primary.Name(), err)
}
t, ferr := c.fallback.Transcribe(ctx, wavPath)
if ferr != nil {
return domain.Transcription{}, errors.Join(
fmt.Errorf("%s: %w", c.primary.Name(), err),
fmt.Errorf("%s: %w", c.fallback.Name(), ferr))
}
t.Endpoint = c.fallback.Name()
return t, nil
}
@@ -0,0 +1,99 @@
package transcribe_test
import (
"context"
"errors"
"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"
)
type fakeBackend struct {
name string
text string
err error
calls int
}
func (f *fakeBackend) Name() string { return f.name }
func (f *fakeBackend) Transcribe(_ context.Context, _ string) (domain.Transcription, error) {
f.calls++
if f.err != nil {
return domain.Transcription{}, f.err
}
return domain.Transcription{Text: f.text, Endpoint: f.name}, nil
}
func TestChain_PrimarySuccessNeverTouchesTheFallback(t *testing.T) {
primary := &fakeBackend{name: "iguana/kb-whisper", text: "primary text"}
fallback := &fakeBackend{name: "berget/kb-whisper", text: "fallback text"}
got, err := transcribe.NewChain(primary, fallback).Transcribe(context.Background(), "/a.wav")
require.NoError(t, err)
assert.Equal(t, "primary text", got.Text)
assert.Equal(t, "iguana/kb-whisper", got.Endpoint)
assert.Zero(t, fallback.calls, "audio must not leave the homelab when the local endpoint worked")
}
func TestChain_AnyPrimaryFailureFallsBackAndRecordsTheFallbackEndpoint(t *testing.T) {
tests := []struct {
name string
err error
}{
{"server error", &transcribe.StatusError{Code: 500}},
{"gateway timeout", &transcribe.StatusError{Code: 504}},
{"client error", &transcribe.StatusError{Code: 400}},
{"transport failure", errors.New("dial tcp: connection refused")},
{"empty transcript", transcribe.ErrEmptyTranscript},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
primary := &fakeBackend{name: "iguana/kb-whisper", err: tt.err}
fallback := &fakeBackend{name: "berget/kb-whisper", text: "fallback text"}
got, err := transcribe.NewChain(primary, fallback).Transcribe(context.Background(), "/a.wav")
require.NoError(t, err)
assert.Equal(t, "fallback text", got.Text)
assert.Equal(t, "berget/kb-whisper", got.Endpoint,
"the row must say berget served it: that means the audio left the homelab")
})
}
}
func TestChain_BothFailingReportsBothErrors(t *testing.T) {
primary := &fakeBackend{name: "iguana/kb-whisper", err: errors.New("iguana down")}
fallback := &fakeBackend{name: "berget/kb-whisper", err: errors.New("berget down")}
_, err := transcribe.NewChain(primary, fallback).Transcribe(context.Background(), "/a.wav")
require.Error(t, err)
assert.Contains(t, err.Error(), "iguana down")
assert.Contains(t, err.Error(), "berget down")
}
func TestChain_WithoutAFallbackAPrimaryFailureIsTheResult(t *testing.T) {
primary := &fakeBackend{name: "iguana/kb-whisper", err: errors.New("iguana down")}
_, err := transcribe.NewChain(primary, nil).Transcribe(context.Background(), "/a.wav")
require.Error(t, err)
assert.Contains(t, err.Error(), "iguana down")
}
func TestChain_ACancelledJobDoesNotFallBack(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
primary := &fakeBackend{name: "iguana/kb-whisper", err: context.Canceled}
fallback := &fakeBackend{name: "berget/kb-whisper", text: "x"}
_, err := transcribe.NewChain(primary, fallback).Transcribe(ctx, "/a.wav")
assert.ErrorIs(t, err, context.Canceled)
assert.Zero(t, fallback.calls, "a cancelled job must not ship the audio to a cloud endpoint")
}
+114
View File
@@ -0,0 +1,114 @@
package transcribe
import (
"context"
"errors"
"fmt"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/errkind"
"git.d-ma.be/mathias/tapir/internal/progress"
"git.d-ma.be/mathias/tapir/internal/reconcile"
)
// Ensemble transcribes the same WAV with two engines, one after the other, and has a
// local model reconcile their disagreements (ADR-034). It satisfies uploads.Transcriber,
// so the worker neither knows nor cares that two engines were involved.
//
// It degrades in steps and never turns a recoverable problem into a failed job:
// one engine down → the other's text, said so on the result; the model down → the
// base text unreconciled; both engines down → the cloud fallback, if configured, and
// recorded as such. A cancelled job stops everything and never reaches the cloud.
type Ensemble struct {
base, alt Backend
fallback Backend // nil = no cloud fallback
adj reconcile.Adjudicator
opts reconcile.Options
}
// NewEnsemble builds an Ensemble. base is the engine whose text stands where the two
// disagree and is the faster one, so it runs first; alt supplies the other reading.
func NewEnsemble(base, alt, fallback Backend, adj reconcile.Adjudicator, opts reconcile.Options) *Ensemble {
return &Ensemble{base: base, alt: alt, fallback: fallback, adj: adj, opts: opts}
}
// Plan implements uploads.Planner: the phases this ensemble reports, so the page can
// show them before they run. The fallback is a phase only when one is configured.
func (e *Ensemble) Plan() []progress.Planned {
plan := []progress.Planned{
{Key: "engine_a", Label: "Transcribing with " + e.base.Name()},
{Key: "engine_b", Label: "Transcribing with " + e.alt.Name()},
{Key: "compare", Label: "Comparing the two transcripts"},
{Key: "decide", Label: "Deciding the differences"},
}
if e.fallback != nil {
plan = append(plan, progress.Planned{Key: "fallback", Label: "Cloud fallback (" + e.fallback.Name() + ")"})
}
return plan
}
// Transcribe implements uploads.Transcriber. Each phase is reported through the
// progress.Reporter in ctx (a no-op when nobody listens).
func (e *Ensemble) Transcribe(ctx context.Context, wavPath string) (domain.Transcription, error) {
rep := progress.From(ctx)
b, berr := e.run(ctx, rep, "engine_a", e.base, wavPath)
if err := ctx.Err(); err != nil {
return domain.Transcription{}, err
}
a, aerr := e.run(ctx, rep, "engine_b", e.alt, wavPath)
if err := ctx.Err(); err != nil {
return domain.Transcription{}, err
}
switch {
case berr == nil && aerr == nil:
out, err := reconcile.Reconcile(ctx, b, a, e.adj, e.opts)
if err != nil {
return domain.Transcription{}, err
}
out.Endpoint = fmt.Sprintf("%s + %s, reconciled by %s", e.base.Name(), e.alt.Name(), e.opts.Reconciler)
if rc := out.Reconciliation; rc != nil {
rep.Note("reconciled", "status", string(rc.Status), "disputes", rc.Disputes, "auto_resolved", rc.AutoResolved,
"adjudicated", rc.Adjudicated, "changed", rc.Changed, "unsure", rc.Unsure, "errors", rc.Errors)
}
return out, nil
case berr == nil:
return e.single(b, e.base.Name(), fmt.Sprintf("%s failed (%s)", e.alt.Name(), errkind.Of(aerr))), nil
case aerr == nil:
return e.single(a, e.alt.Name(), fmt.Sprintf("%s failed (%s)", e.base.Name(), errkind.Of(berr))), nil
}
both := errors.Join(fmt.Errorf("%s: %w", e.base.Name(), berr), fmt.Errorf("%s: %w", e.alt.Name(), aerr))
if e.fallback == nil {
return domain.Transcription{}, both
}
f, ferr := e.run(ctx, rep, "fallback", e.fallback, wavPath)
if ferr != nil {
return domain.Transcription{}, errors.Join(both, fmt.Errorf("%s: %w", e.fallback.Name(), ferr))
}
return e.single(f, e.fallback.Name(), "both local engines failed"), nil
}
// run is one engine call, reported as a phase. A job that was stopped is not a broken
// engine, so a cancelled context is never reported as a failed phase.
func (e *Ensemble) run(ctx context.Context, rep progress.Reporter, key string, be Backend, wav string) (domain.Transcription, error) {
rep.Begin(key, 0)
t, err := be.Transcribe(ctx, wav)
switch {
case err == nil:
rep.Done(key)
case ctx.Err() == nil:
rep.Fail(key, err)
}
return t, err
}
// single is a transcript from one engine only: nothing to reconcile, and the result
// says why, because a reader should know the second opinion was missing.
func (e *Ensemble) single(t domain.Transcription, engine, reason string) domain.Transcription {
t.Endpoint = engine
t.Reconciliation = &domain.Reconciliation{
Status: domain.ReconcileSingle, Reason: reason, Base: e.opts.BaseEngine, Alt: e.opts.AltEngine, Reconciler: e.opts.Reconciler,
}
return t
}
@@ -0,0 +1,162 @@
package transcribe_test
import (
"context"
"errors"
"strings"
"sync"
"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/progress"
"git.d-ma.be/mathias/tapir/internal/reconcile"
)
// What the Ensemble reports to the page and the log (ADR-035). The reporter here is a
// recording double carried in the context, exactly as the worker's Tracker is.
type recReporter struct {
mu sync.Mutex
events []string
notes []map[string]any
}
func (r *recReporter) add(e string) {
r.mu.Lock()
defer r.mu.Unlock()
r.events = append(r.events, e)
}
func (r *recReporter) Begin(key string, _ int) { r.add("begin:" + key) }
func (r *recReporter) Advance(key string, _ int) { r.add("advance:" + key) }
func (r *recReporter) Done(key string) { r.add("done:" + key) }
func (r *recReporter) Fail(key string, _ error) { r.add("fail:" + key) }
func (r *recReporter) Note(msg string, kv ...any) {
m := map[string]any{"msg": msg}
for i := 0; i+1 < len(kv); i += 2 {
m[kv[i].(string)] = kv[i+1]
}
r.mu.Lock()
r.notes = append(r.notes, m)
r.mu.Unlock()
r.add("note:" + msg)
}
func (r *recReporter) joined() string {
r.mu.Lock()
defer r.mu.Unlock()
return strings.Join(r.events, ",")
}
func TestEnsemblePlan_DeclaresBothEnginesAndTheReconcileStepsByName(t *testing.T) {
r := newEnsemble("a", "a", nil, nil, false, &verdictAdj{})
plan := r.e.Plan()
require.Len(t, plan, 4)
assert.Equal(t, []string{"engine_a", "engine_b", "compare", "decide"}, []string{plan[0].Key, plan[1].Key, plan[2].Key, plan[3].Key})
assert.Contains(t, plan[0].Label, "iguana/pianissimo", "the page says which engine is running")
assert.Contains(t, plan[1].Label, "iguana/kb-whisper")
}
func TestEnsemblePlan_TheCloudFallbackIsAStepOnlyWhenOneIsConfigured(t *testing.T) {
with := newEnsemble("a", "a", nil, nil, true, &verdictAdj{}).e.Plan()
without := newEnsemble("a", "a", nil, nil, false, &verdictAdj{}).e.Plan()
assert.Equal(t, "fallback", with[len(with)-1].Key)
assert.Len(t, without, 4, "no fallback configured: the page must not promise one")
}
func TestEnsembleProgress_BothEnginesAreReportedInTheOrderTheyRun(t *testing.T) {
rep := &recReporter{}
r := newEnsemble("Totalt 1015 kronor idag.", "Totalt 1050 kronor idag.", nil, nil, false, &verdictAdj{v: reconcile.VerdictAlt})
_, err := r.e.Transcribe(progress.With(context.Background(), rep), "/a.wav")
require.NoError(t, err)
got := rep.joined()
a, aDone := strings.Index(got, "begin:engine_a"), strings.Index(got, "done:engine_a")
b, bDone := strings.Index(got, "begin:engine_b"), strings.Index(got, "done:engine_b")
require.True(t, a >= 0 && aDone > a && b > aDone && bDone > b, "events out of order: %s", got)
assert.NotContains(t, got, "fail:")
}
func TestEnsembleProgress_AnEngineThatFailsIsShownFailedAndTheOtherStillRuns(t *testing.T) {
rep := &recReporter{}
r := newEnsemble("text", "text", errors.New("pianissimo down"), nil, false, &verdictAdj{})
got, err := r.e.Transcribe(progress.With(context.Background(), rep), "/a.wav")
require.NoError(t, err, "one engine down is a degraded result, not a failed job")
assert.Equal(t, domain.ReconcileSingle, got.Reconciliation.Status)
ev := rep.joined()
assert.Contains(t, ev, "fail:engine_a")
assert.Contains(t, ev, "done:engine_b")
assert.NotContains(t, ev, "begin:compare", "nothing to compare with one engine")
}
func TestEnsembleProgress_TheFallbackIsReportedOnlyWhenBothEnginesFailed(t *testing.T) {
rep := &recReporter{}
r := newEnsemble("t", "t", errors.New("a down"), errors.New("b down"), true, &verdictAdj{})
_, err := r.e.Transcribe(progress.With(context.Background(), rep), "/a.wav")
require.NoError(t, err)
ev := rep.joined()
assert.Contains(t, ev, "fail:engine_a")
assert.Contains(t, ev, "fail:engine_b")
assert.Contains(t, ev, "begin:fallback")
assert.Contains(t, ev, "done:fallback")
rep2 := &recReporter{}
ok := newEnsemble("x", "x", nil, nil, true, &verdictAdj{})
_, err = ok.e.Transcribe(progress.With(context.Background(), rep2), "/a.wav")
require.NoError(t, err)
assert.NotContains(t, rep2.joined(), "fallback", "a healthy run never touches the cloud step")
}
// cancellingBackend ends the job in the middle of its own call, like a shutdown.
type cancellingBackend struct {
name string
cancel context.CancelFunc
}
func (c cancellingBackend) Name() string { return c.name }
func (c cancellingBackend) Transcribe(ctx context.Context, _ string) (domain.Transcription, error) {
c.cancel()
return domain.Transcription{}, ctx.Err()
}
func TestEnsembleProgress_AShutdownMidEngineIsNotReportedAsAFailedEngine(t *testing.T) {
rep := &recReporter{}
ctx, cancel := context.WithCancel(progress.With(context.Background(), rep))
calls := &[]string{}
alt := &orderedBackend{name: "iguana/kb-whisper", text: "x", calls: calls}
e := transcribe.NewEnsemble(cancellingBackend{name: "iguana/pianissimo", cancel: cancel}, alt, nil, &verdictAdj{}, ensembleOpts)
_, err := e.Transcribe(ctx, "/a.wav")
require.ErrorIs(t, err, context.Canceled)
assert.NotContains(t, rep.joined(), "fail:", "a stopped job is not a broken engine: the page must not blame one")
assert.Empty(t, *calls, "and the second engine never starts")
}
func TestEnsembleProgress_TheReconcileCountsAreNotedForTheOperator(t *testing.T) {
rep := &recReporter{}
r := newEnsemble("Totalt 1015 kronor idag.", "Totalt 1050 kronor idag.", nil, nil, false, &verdictAdj{v: reconcile.VerdictAlt})
_, err := r.e.Transcribe(progress.With(context.Background(), rep), "/a.wav")
require.NoError(t, err)
require.Len(t, rep.notes, 1)
n := rep.notes[0]
assert.Equal(t, "reconciled", n["msg"])
assert.Equal(t, 1, n["disputes"])
assert.Equal(t, 1, n["changed"])
assert.Equal(t, 0, n["unsure"])
assert.Equal(t, 0, n["errors"])
}
@@ -0,0 +1,36 @@
package transcribe_test
import (
"context"
"errors"
"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/reconcile"
)
// When one engine is down the page says so and why; the "why" is a kind, not the
// engine's error text, which can carry provider output (adversarial review F3).
func TestEnsemble_AFailedEnginesReasonIsAKindNotItsErrorText(t *testing.T) {
r := newEnsemble("text", "text", nil, errors.New("kb-whisper said: zebrakaka secret words"), false, &verdictAdj{v: reconcile.VerdictAlt})
got, err := r.e.Transcribe(context.Background(), "/a.wav")
require.NoError(t, err)
assert.NotContains(t, got.Reconciliation.Reason, "zebrakaka")
assert.Contains(t, got.Reconciliation.Reason, "iguana/kb-whisper failed", "which engine failed is still said")
}
func TestEnsemble_AnHTTPFailureShowsItsStatusCodeAsTheKind(t *testing.T) {
r := newEnsemble("text", "text", &transcribe.StatusError{Code: 502, Snippet: "zebrakaka"}, nil, false, &verdictAdj{})
got, err := r.e.Transcribe(context.Background(), "/a.wav")
require.NoError(t, err)
assert.Contains(t, got.Reconciliation.Reason, "http_502")
assert.NotContains(t, got.Reconciliation.Reason, "zebrakaka")
}
@@ -0,0 +1,206 @@
package transcribe_test
import (
"context"
"errors"
"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/reconcile"
)
// orderedBackend records, in a shared slice, when it was called.
type orderedBackend struct {
name string
text string
err error
calls *[]string
}
func (o *orderedBackend) Name() string { return o.name }
func (o *orderedBackend) Transcribe(_ context.Context, _ string) (domain.Transcription, error) {
*o.calls = append(*o.calls, o.name)
if o.err != nil {
return domain.Transcription{}, o.err
}
return domain.Transcription{Text: o.text, Endpoint: o.name, AudioSeconds: 10}, nil
}
type verdictAdj struct {
v reconcile.Verdict
err error
calls int
}
func (a *verdictAdj) Adjudicate(context.Context, reconcile.Request) (reconcile.Verdict, error) {
a.calls++
return a.v, a.err
}
var ensembleOpts = reconcile.Options{Reconciler: "iguana/gemma4-26b", BaseEngine: "iguana/pianissimo", AltEngine: "iguana/kb-whisper"}
type rig struct {
e *transcribe.Ensemble
calls *[]string
adj *verdictAdj
}
func newEnsemble(baseText, altText string, baseErr, altErr error, withFallback bool, adj *verdictAdj) *rig {
calls := &[]string{}
base := &orderedBackend{name: "iguana/pianissimo", text: baseText, err: baseErr, calls: calls}
alt := &orderedBackend{name: "iguana/kb-whisper", text: altText, err: altErr, calls: calls}
var fb transcribe.Backend
if withFallback {
fb = &orderedBackend{name: "berget/kb-whisper", text: "fallback text", calls: calls}
}
return &rig{e: transcribe.NewEnsemble(base, alt, fb, adj, ensembleOpts), calls: calls, adj: adj}
}
func TestEnsemble_BothEnginesRunInOrderAndTheirDisagreementIsReconciled(t *testing.T) {
adj := &verdictAdj{v: reconcile.VerdictAlt}
r := newEnsemble("Totalt 1015 kronor idag.", "Totalt 1050 kronor idag.", nil, nil, true, adj)
got, err := r.e.Transcribe(context.Background(), "/a.wav")
require.NoError(t, err)
assert.Equal(t, []string{"iguana/pianissimo", "iguana/kb-whisper"}, *r.calls,
"the 40-second engine runs first, so a failure there is known early; the fallback is not touched")
assert.Equal(t, "Totalt 1050 kronor idag.", got.Text)
assert.Equal(t, "iguana/pianissimo + iguana/kb-whisper, reconciled by iguana/gemma4-26b", got.Endpoint,
"every contributor is recorded: which engines touched the audio is a quality and a privacy fact")
require.NotNil(t, got.Reconciliation)
assert.Equal(t, domain.ReconcileDone, got.Reconciliation.Status)
}
func TestEnsemble_IdenticalTextsNeverCallTheModel(t *testing.T) {
adj := &verdictAdj{v: reconcile.VerdictAlt}
r := newEnsemble("Vi går igenom läget.", "Vi går igenom läget.", nil, nil, false, adj)
got, err := r.e.Transcribe(context.Background(), "/a.wav")
require.NoError(t, err)
assert.Zero(t, adj.calls)
assert.Equal(t, "Vi går igenom läget.", got.Text)
}
func TestEnsemble_WhenOneEngineFailsTheOtherIsUsedAndItIsSaidSo(t *testing.T) {
tests := []struct {
name string
baseErr error
altErr error
wantText string
wantEndpoint string
wantReasonHas string
}{
{"pianissimo down", errors.New("pianissimo wedged"), nil, "alt text", "iguana/kb-whisper", "iguana/pianissimo failed"},
{"kb-whisper down", nil, errors.New("metal oom"), "base text", "iguana/pianissimo", "iguana/kb-whisper failed"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
adj := &verdictAdj{v: reconcile.VerdictAlt}
r := newEnsemble("base text", "alt text", tt.baseErr, tt.altErr, true, adj)
got, err := r.e.Transcribe(context.Background(), "/a.wav")
require.NoError(t, err)
assert.Equal(t, tt.wantText, got.Text)
assert.Equal(t, tt.wantEndpoint, got.Endpoint)
assert.Zero(t, adj.calls, "there is nothing to reconcile")
require.NotNil(t, got.Reconciliation)
assert.Equal(t, domain.ReconcileSingle, got.Reconciliation.Status)
assert.Contains(t, got.Reconciliation.Reason, tt.wantReasonHas)
assert.NotContains(t, *r.calls, "berget/kb-whisper", "one local engine working means audio does not leave the homelab")
})
}
}
func TestEnsemble_OnlyWhenBothLocalEnginesFailDoesTheCloudFallbackRun(t *testing.T) {
r := newEnsemble("", "", errors.New("a"), errors.New("b"), true, &verdictAdj{})
got, err := r.e.Transcribe(context.Background(), "/a.wav")
require.NoError(t, err)
assert.Equal(t, "fallback text", got.Text)
assert.Equal(t, "berget/kb-whisper", got.Endpoint, "the row must say berget served it: the audio left the homelab")
assert.Equal(t, domain.ReconcileSingle, got.Reconciliation.Status)
assert.Contains(t, got.Reconciliation.Reason, "both local engines failed")
}
func TestEnsemble_BothFailingWithNoFallbackReportsBothErrors(t *testing.T) {
r := newEnsemble("", "", errors.New("pianissimo down"), errors.New("whisper down"), false, &verdictAdj{})
_, err := r.e.Transcribe(context.Background(), "/a.wav")
require.Error(t, err)
assert.Contains(t, err.Error(), "pianissimo down")
assert.Contains(t, err.Error(), "whisper down")
}
func TestEnsemble_ACancelledJobNeverReachesTheCloud(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
r := newEnsemble("", "", context.Canceled, context.Canceled, true, &verdictAdj{})
_, err := r.e.Transcribe(ctx, "/a.wav")
assert.ErrorIs(t, err, context.Canceled)
assert.NotContains(t, *r.calls, "berget/kb-whisper", "a cancelled job must not ship its audio to a cloud endpoint as a last act")
}
func TestEnsemble_AModelThatIsDownGivesTheBaseTextUnreconciledNotAFailedJob(t *testing.T) {
adj := &verdictAdj{err: errors.New("gateway down")}
// Four separate disagreements, each separated by two shared words, so the
// breaker trips after three consecutive failures.
r := newEnsemble("a1 s t b1 s t c1 s t d1", "a2 s t b2 s t c2 s t d2", nil, nil, false, adj)
got, err := r.e.Transcribe(context.Background(), "/a.wav")
require.NoError(t, err)
assert.Equal(t, "a1 s t b1 s t c1 s t d1", got.Text)
assert.Equal(t, domain.ReconcileSkipped, got.Reconciliation.Status)
assert.Equal(t, 3, adj.calls)
}
// cancelling is a backend that cancels the job's context while it runs, the way a
// shutdown arrives in the middle of a long transcription.
type cancelling struct {
name string
cancel context.CancelFunc
calls *[]string
}
func (c *cancelling) Name() string { return c.name }
func (c *cancelling) Transcribe(_ context.Context, _ string) (domain.Transcription, error) {
*c.calls = append(*c.calls, c.name)
c.cancel()
return domain.Transcription{}, context.Canceled
}
func TestEnsemble_AShutdownDuringTheFirstEngineStopsBeforeTheSecondOrTheCloud(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
calls := &[]string{}
base := &cancelling{name: "iguana/pianissimo", cancel: cancel, calls: calls}
alt := &orderedBackend{name: "iguana/kb-whisper", text: "alt", calls: calls}
fb := &orderedBackend{name: "berget/kb-whisper", text: "fb", calls: calls}
_, err := transcribe.NewEnsemble(base, alt, fb, &verdictAdj{}, ensembleOpts).Transcribe(ctx, "/a.wav")
assert.ErrorIs(t, err, context.Canceled)
assert.Equal(t, []string{"iguana/pianissimo"}, *calls, "no second engine and no cloud once the job is cancelled")
}
func TestEnsemble_AShutdownDuringTheSecondEngineIsAnErrorNotASingleEngineSuccess(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
calls := &[]string{}
base := &orderedBackend{name: "iguana/pianissimo", text: "base", calls: calls}
alt := &cancelling{name: "iguana/kb-whisper", cancel: cancel, calls: calls}
got, err := transcribe.NewEnsemble(base, alt, nil, &verdictAdj{}, ensembleOpts).Transcribe(ctx, "/a.wav")
assert.ErrorIs(t, err, context.Canceled, "a cancelled job must not be recorded as 'kb-whisper failed'")
assert.Empty(t, got.Text)
}
+188
View File
@@ -0,0 +1,188 @@
// Package transcribe turns a WAV file into a timed Swedish transcript by calling
// the LiteLLM gateway's OpenAI-compatible /audio/transcriptions endpoint
// (ADR-033). A Gateway is one model alias; a Chain tries a local one and falls
// back to a second.
package transcribe
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"strings"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// ErrEmptyTranscript is returned when the endpoint answered 200 but with no text.
// An empty transcript is not a success: chat over it could only answer "nothing".
var ErrEmptyTranscript = errors.New("transcribe: endpoint returned an empty transcript")
// StatusError is a non-2xx answer from the gateway. Nothing may branch on the
// body — {"error":"failed to process audio"} has had at least five distinct
// causes — so only the status code is exposed for logic; Snippet is for logs.
type StatusError struct {
Code int
Snippet string
}
// HTTPStatus implements errkind.HTTPStatuser: the code, never the snippet.
func (e *StatusError) HTTPStatus() int { return e.Code }
func (e *StatusError) Error() string {
return fmt.Sprintf("transcribe: gateway returned HTTP %d: %s", e.Code, e.Snippet)
}
// Gateway transcribes through one model alias on the gateway. baseURL is the
// gateway's /v1 root — the in-cluster NodePort, not the public hostname, whose
// ingress rejects bodies over ~8-10 MB with 413 (docs/research/audio-length-ceiling).
type Gateway struct {
baseURL string
key string
model string
client *http.Client
}
// NewGateway builds a Gateway. The client should carry no overall Timeout: a 90
// minute recording takes minutes, so the deadline belongs to the caller's context.
func NewGateway(baseURL, key, model string, client *http.Client) *Gateway {
return &Gateway{baseURL: strings.TrimRight(baseURL, "/"), key: key, model: model, client: client}
}
// Name is the model alias; it is what gets recorded as the serving endpoint.
func (g *Gateway) Name() string { return g.model }
// Transcribe uploads the WAV at path and returns the normalised transcription.
// The body is streamed from disk with a COMPUTED Content-Length: a 90 minute WAV
// is ~170 MB, so it is neither buffered in memory nor sent chunked.
func (g *Gateway) Transcribe(ctx context.Context, path string) (domain.Transcription, error) {
f, err := os.Open(path)
if err != nil {
return domain.Transcription{}, fmt.Errorf("transcribe: open audio: %w", err)
}
defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil {
return domain.Transcription{}, fmt.Errorf("transcribe: stat audio: %w", err)
}
var prefix bytes.Buffer
mw := multipart.NewWriter(&prefix)
for _, kv := range [][2]string{{"model", g.model}, {"response_format", "verbose_json"}, {"language", "sv"}} {
if err := mw.WriteField(kv[0], kv[1]); err != nil {
return domain.Transcription{}, fmt.Errorf("transcribe: build request: %w", err)
}
}
// The multipart field is `file`; `audio_file` is rejected as "Invalid request".
if _, err := mw.CreateFormFile("file", "audio.wav"); err != nil {
return domain.Transcription{}, fmt.Errorf("transcribe: build request: %w", err)
}
suffix := []byte("\r\n--" + mw.Boundary() + "--\r\n")
body := io.MultiReader(bytes.NewReader(prefix.Bytes()), f, bytes.NewReader(suffix))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, g.baseURL+"/audio/transcriptions", body)
if err != nil {
return domain.Transcription{}, fmt.Errorf("transcribe: build request: %w", err)
}
req.ContentLength = int64(prefix.Len()) + info.Size() + int64(len(suffix))
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+g.key)
// No "Expect: 100-continue": it trips the proxy for any body over 1 KB.
resp, err := g.client.Do(req)
if err != nil {
return domain.Transcription{}, fmt.Errorf("transcribe: %s: %w", g.model, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
snip, _ := io.ReadAll(io.LimitReader(resp.Body, 200))
return domain.Transcription{}, &StatusError{Code: resp.StatusCode, Snippet: strings.TrimSpace(string(snip))}
}
t, err := parseVerbose(resp.Body)
if err != nil {
return domain.Transcription{}, fmt.Errorf("transcribe: %s: %w", g.model, err)
}
t.Endpoint = g.model
return t, nil
}
// verboseJSON is the subset of the verbose_json response used. Both backends
// return it; they differ in segment granularity (iguana: whole seconds and
// sub-sentence pieces, berget: sub-second sentences), in the language token, and in
// whether a segment's text starts with a space.
type verboseJSON struct {
Text string `json:"text"`
Duration float64 `json:"duration"`
Segments []struct {
Start float64 `json:"start"`
End float64 `json:"end"`
Text string `json:"text"`
} `json:"segments"`
}
// parseVerbose normalises a verbose_json body. Whisper's final window can end past
// the audio (9.0 s for 4.25 s of audio was observed), so segment ends are clamped
// to the reported duration. Text is rebuilt from the segments, not taken from the
// response's `text` field: see joinSegments.
func parseVerbose(r io.Reader) (domain.Transcription, error) {
var v verboseJSON
if err := json.NewDecoder(r).Decode(&v); err != nil {
return domain.Transcription{}, fmt.Errorf("decode response: %w", err)
}
raw := make([]string, 0, len(v.Segments))
for _, s := range v.Segments {
raw = append(raw, s.Text)
}
text := joinSegments(raw)
if text == "" {
text = collapseSpace(v.Text)
}
if text == "" {
return domain.Transcription{}, ErrEmptyTranscript
}
t := domain.Transcription{Text: text, AudioSeconds: v.Duration}
for _, s := range v.Segments {
seg := domain.Segment{Start: s.Start, End: s.End, Text: strings.TrimSpace(s.Text)}
if v.Duration > 0 {
seg.End = min(seg.End, v.Duration)
seg.Start = min(seg.Start, seg.End)
}
t.Segments = append(t.Segments, seg)
}
return t, nil
}
// joinSegments rebuilds the running text from raw (untrimmed) segment texts.
//
// whisper.cpp wraps its output at ~60 characters and a wrap can land INSIDE a word
// ("nyt" | "t godkännande"): a segment that begins with a space starts a new word,
// one that does not continues the previous one, and the response's own `text`
// field joins them with newlines, which breaks the word. So when any segment
// carries a leading space the raw texts are concatenated as they are. Backends
// that return whole sentences with no leading space (berget) are joined with a
// space instead — concatenating those would glue sentences together.
func joinSegments(raw []string) string {
leadingSpace := false
for _, s := range raw {
if strings.HasPrefix(s, " ") {
leadingSpace = true
break
}
}
sep := " "
if leadingSpace {
sep = ""
}
return collapseSpace(strings.Join(raw, sep))
}
// collapseSpace turns every run of whitespace (including newlines) into one space.
func collapseSpace(s string) string {
return strings.Join(strings.Fields(s), " ")
}
@@ -0,0 +1,257 @@
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")
}
+49
View File
@@ -0,0 +1,49 @@
package transcribe_test
import (
"context"
"net/http"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/adapters/transcribe"
)
// TestGateway_LiveGateway is an opt-in check against the REAL gateway, because
// the httptest fake above can only prove what we think the gateway accepts. It
// is skipped unless TAPIR_LIVE_WAV names a 16 kHz mono WAV and the gateway
// coordinates are set:
//
// TAPIR_LIVE_WAV=/path/a.wav TAPIR_LIVE_GATEWAY_URL=http://koala:30401/v1 \
// TAPIR_LIVE_MODEL=iguana/kb-whisper op run --env-file ~/.op-env -- \
// go test ./internal/adapters/transcribe -run LiveGateway -v
//
// Use SYNTHETIC audio only: this repo is public and the gateway may be a cloud
// endpoint (#33).
func TestGateway_LiveGateway(t *testing.T) {
wav := os.Getenv("TAPIR_LIVE_WAV")
url := os.Getenv("TAPIR_LIVE_GATEWAY_URL")
if wav == "" || url == "" {
t.Skip("set TAPIR_LIVE_WAV and TAPIR_LIVE_GATEWAY_URL to run against the real gateway")
}
model := os.Getenv("TAPIR_LIVE_MODEL")
if model == "" {
model = "iguana/kb-whisper"
}
key := os.Getenv("LITELLM_MASTER_KEY")
require.NotEmpty(t, key, "LITELLM_MASTER_KEY must be set (via op run)")
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Minute)
defer cancel()
got, err := transcribe.NewGateway(url, key, model, &http.Client{}).Transcribe(ctx, wav)
require.NoError(t, err)
assert.NotEmpty(t, got.Text)
assert.NotEmpty(t, got.Segments)
assert.Equal(t, model, got.Endpoint)
t.Logf("endpoint=%s audio_s=%.1f segments=%d chars=%d", got.Endpoint, got.AudioSeconds, len(got.Segments), len(got.Text))
}
+33
View File
@@ -0,0 +1,33 @@
package transcribe
import (
"context"
"time"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// WithTimeout gives every call to b its own deadline. A wedged engine does not answer at
// all (iguana after a Metal OOM, infra#273), and with only the job's deadline it used up
// the whole job before a fallback could run -- and Chain then refuses the fallback because
// the job context has expired. With its own deadline the call fails while the job is still
// alive, which is exactly when the fallback, or the other engine, can take over.
//
// A zero or negative d returns b unchanged. The wrapper is invisible: Name is b's.
func WithTimeout(b Backend, d time.Duration) Backend {
if d <= 0 {
return b
}
return timeoutBackend{Backend: b, d: d}
}
type timeoutBackend struct {
Backend
d time.Duration
}
func (t timeoutBackend) Transcribe(ctx context.Context, wavPath string) (domain.Transcription, error) {
ctx, cancel := context.WithTimeout(ctx, t.d)
defer cancel()
return t.Backend.Transcribe(ctx, wavPath)
}
@@ -0,0 +1,104 @@
package transcribe_test
import (
"context"
"errors"
"testing"
"time"
"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"
"git.d-ma.be/mathias/tapir/internal/reconcile"
)
// A wedged engine (iguana after a Metal OOM, infra#273) does not answer at all. With only
// the job's deadline, the primary used up the whole job before any fallback could run, and
// the chain then refused the fallback because the job context had expired. Each engine call
// gets its own deadline (adversarial review F4).
type hangingBackend struct{ name string }
func (h hangingBackend) Name() string { return h.name }
func (hangingBackend) Transcribe(ctx context.Context, _ string) (domain.Transcription, error) {
<-ctx.Done()
return domain.Transcription{}, ctx.Err()
}
func TestWithTimeout_AHungBackendFailsAtItsOwnDeadlineWhileTheJobIsStillAlive(t *testing.T) {
job, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
b := transcribe.WithTimeout(hangingBackend{name: "iguana/kb-whisper"}, 40*time.Millisecond)
start := time.Now()
_, err := b.Transcribe(job, "/a.wav")
require.Error(t, err)
assert.Equal(t, "timeout", errkind.Of(err))
assert.Less(t, time.Since(start), 2*time.Second, "it must give up at its own deadline, not wait for the job's")
assert.NoError(t, job.Err(), "the job context is untouched, which is what lets a fallback run")
assert.Equal(t, "iguana/kb-whisper", b.Name(), "the wrapper is invisible in the recorded endpoint")
}
func TestWithTimeout_ASuccessPassesStraightThrough(t *testing.T) {
calls := &[]string{}
inner := &orderedBackend{name: "iguana/kb-whisper", text: "hej", calls: calls}
got, err := transcribe.WithTimeout(inner, time.Minute).Transcribe(context.Background(), "/a.wav")
require.NoError(t, err)
assert.Equal(t, "hej", got.Text)
}
func TestWithTimeout_ZeroMeansNoDeadlineOfItsOwn(t *testing.T) {
inner := &orderedBackend{name: "x", text: "hej", calls: &[]string{}}
assert.Same(t, inner, transcribe.WithTimeout(inner, 0), "a zero timeout must not wrap or change anything")
}
func TestChain_AHungPrimaryReachesTheFallbackWhenItHasItsOwnDeadline(t *testing.T) {
calls := &[]string{}
fb := &orderedBackend{name: "berget/kb-whisper", text: "fallback text", calls: calls}
c := transcribe.NewChain(transcribe.WithTimeout(hangingBackend{name: "iguana/kb-whisper"}, 40*time.Millisecond), fb)
job, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
got, err := c.Transcribe(job, "/a.wav")
require.NoError(t, err, "a wedged primary is exactly what the fallback exists for")
assert.Equal(t, "berget/kb-whisper", got.Endpoint)
}
func TestChain_AJobThatEndedWhileThePrimaryHungStillNeverReachesTheFallback(t *testing.T) {
calls := &[]string{}
fb := &orderedBackend{name: "berget/kb-whisper", text: "fallback text", calls: calls}
c := transcribe.NewChain(transcribe.WithTimeout(hangingBackend{name: "iguana/kb-whisper"}, time.Minute), fb)
job, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
defer cancel()
_, err := c.Transcribe(job, "/a.wav")
require.Error(t, err)
assert.Empty(t, *calls, "the job's own deadline or a cancel must still stop the audio from reaching a cloud endpoint")
}
func TestEnsemble_AHungEngineIsASoftFailureAndTheOtherEnginesTextIsUsed(t *testing.T) {
calls := &[]string{}
alt := &orderedBackend{name: "iguana/kb-whisper", text: "text from the other engine", calls: calls}
e := transcribe.NewEnsemble(
transcribe.WithTimeout(hangingBackend{name: "iguana/pianissimo"}, 40*time.Millisecond), alt, nil,
&verdictAdj{v: reconcile.VerdictAlt}, ensembleOpts)
job, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
got, err := e.Transcribe(job, "/a.wav")
require.NoError(t, err)
assert.Equal(t, "text from the other engine", got.Text)
assert.Equal(t, domain.ReconcileSingle, got.Reconciliation.Status)
assert.Contains(t, got.Reconciliation.Reason, "timeout")
assert.False(t, errors.Is(err, context.DeadlineExceeded))
}