diff --git a/internal/adapters/transcribe/ensemble.go b/internal/adapters/transcribe/ensemble.go index 262e5fd..27387be 100644 --- a/internal/adapters/transcribe/ensemble.go +++ b/internal/adapters/transcribe/ensemble.go @@ -51,6 +51,9 @@ func (e *Ensemble) Plan() []progress.Planned { // 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) + if domain.LanguageFrom(ctx) != domain.LangSwedish { + return e.transcribeOther(ctx, rep, wavPath) + } b, berr := e.run(ctx, rep, "engine_a", e.base, wavPath) if err := ctx.Err(); err != nil { return domain.Transcription{}, err @@ -89,6 +92,28 @@ func (e *Ensemble) Transcribe(ctx context.Context, wavPath string) (domain.Trans return e.single(f, e.fallback.Name(), "both local engines failed"), nil } +// transcribeOther handles a recording that is not Swedish (tapir#61). The base engine is +// Swedish-only, so it never receives the audio, and with one reading there is nothing to +// reconcile: the other engine transcribes alone, then the cloud fallback if it fails and the +// job is still live. The phases that did not run are shown as skipped by the tracker. +func (e *Ensemble) transcribeOther(ctx context.Context, rep progress.Reporter, wavPath string) (domain.Transcription, error) { + t, err := e.run(ctx, rep, "engine_b", e.alt, wavPath) + if err == nil { + t.Endpoint = e.alt.Name() + return t, nil + } + if e.fallback == nil || ctx.Err() != nil { + return domain.Transcription{}, fmt.Errorf("%s: %w", e.alt.Name(), err) + } + f, ferr := e.run(ctx, rep, "fallback", e.fallback, wavPath) + if ferr != nil { + return domain.Transcription{}, errors.Join( + fmt.Errorf("%s: %w", e.alt.Name(), err), fmt.Errorf("%s: %w", e.fallback.Name(), ferr)) + } + f.Endpoint = e.fallback.Name() + return f, 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) { diff --git a/internal/adapters/transcribe/ensemble_test.go b/internal/adapters/transcribe/ensemble_test.go index f9feca4..a4dab38 100644 --- a/internal/adapters/transcribe/ensemble_test.go +++ b/internal/adapters/transcribe/ensemble_test.go @@ -204,3 +204,52 @@ func TestEnsemble_AShutdownDuringTheSecondEngineIsAnErrorNotASingleEngineSuccess assert.ErrorIs(t, err, context.Canceled, "a cancelled job must not be recorded as 'kb-whisper failed'") assert.Empty(t, got.Text) } + +// The base engine is Swedish-only. A recording that is not Swedish must never reach it, and +// there is no second opinion to reconcile: the other engine transcribes alone (tapir#61). + +func autoCtx() context.Context { return domain.WithLanguage(context.Background(), domain.LangAuto) } + +func TestEnsemble_AnAutoLanguageUploadNeverReachesTheBaseEngineAndIsNotReconciled(t *testing.T) { + adj := &verdictAdj{v: reconcile.VerdictAlt} + r := newEnsemble("base text", "alt text", nil, nil, true, adj) + + got, err := r.e.Transcribe(autoCtx(), "/a.wav") + + require.NoError(t, err) + assert.Equal(t, []string{"iguana/kb-whisper"}, *r.calls, "only the other engine is called") + assert.Zero(t, adj.calls, "nothing is adjudicated") + assert.Equal(t, "alt text", got.Text) + assert.Equal(t, "iguana/kb-whisper", got.Endpoint) + assert.Nil(t, got.Reconciliation, "reconciliation does not apply, so none is recorded") +} + +func TestEnsemble_AnAutoLanguageUploadFallsBackWhenItsEngineFailsAndStillSkipsTheBaseEngine(t *testing.T) { + r := newEnsemble("base text", "alt text", nil, errors.New("engine down"), true, &verdictAdj{}) + + got, err := r.e.Transcribe(autoCtx(), "/a.wav") + + require.NoError(t, err) + assert.Equal(t, []string{"iguana/kb-whisper", "berget/kb-whisper"}, *r.calls) + assert.Equal(t, "berget/kb-whisper", got.Endpoint) +} + +func TestEnsemble_AnAutoLanguageUploadWithNoFallbackFailsWithoutTouchingTheBaseEngine(t *testing.T) { + r := newEnsemble("base text", "alt text", nil, errors.New("engine down"), false, &verdictAdj{}) + + _, err := r.e.Transcribe(autoCtx(), "/a.wav") + + require.Error(t, err) + assert.Equal(t, []string{"iguana/kb-whisper"}, *r.calls) +} + +func TestEnsemble_ACancelledAutoLanguageJobDoesNotReachTheFallback(t *testing.T) { + r := newEnsemble("base text", "alt text", nil, errors.New("engine down"), true, &verdictAdj{}) + ctx, cancel := context.WithCancel(autoCtx()) + cancel() + + _, err := r.e.Transcribe(ctx, "/a.wav") + + require.Error(t, err) + assert.NotContains(t, *r.calls, "berget/kb-whisper", "a stopped job must not ship its audio to a cloud endpoint") +}