feat(uploads): choose the language of a recording; Swedish default, Other = auto (#61 interim) #62

Merged
mathias merged 10 commits from sprint/language into main 2026-10-05 04:59:03 +00:00
2 changed files with 74 additions and 0 deletions
Showing only changes of commit ed9d76b0cf - Show all commits
+25
View File
@@ -51,6 +51,9 @@ func (e *Ensemble) Plan() []progress.Planned {
// progress.Reporter in ctx (a no-op when nobody listens). // progress.Reporter in ctx (a no-op when nobody listens).
func (e *Ensemble) Transcribe(ctx context.Context, wavPath string) (domain.Transcription, error) { func (e *Ensemble) Transcribe(ctx context.Context, wavPath string) (domain.Transcription, error) {
rep := progress.From(ctx) 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) b, berr := e.run(ctx, rep, "engine_a", e.base, wavPath)
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return domain.Transcription{}, err 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 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 // 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. // 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) { func (e *Ensemble) run(ctx context.Context, rep progress.Reporter, key string, be Backend, wav string) (domain.Transcription, error) {
@@ -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.ErrorIs(t, err, context.Canceled, "a cancelled job must not be recorded as 'kb-whisper failed'")
assert.Empty(t, got.Text) 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")
}