Files
tapir/internal/adapters/transcribe/ensemble.go
T
mathiasandClaude Sonnet 5.5 34bc8dbdee feat(transcribe): Ensemble -- two local engines, a local reconciler, fail-soft at every step
Runs the fast engine then the other from one WAV and reconciles; one engine down
uses the other and says so; the model down gives the base text unreconciled; the
cloud fallback runs only when BOTH local engines fail and is recorded as such; a
cancelled job stops everything and never reaches the cloud. The endpoint lists every
contributor. Mutation-checked, including both cancellation points.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:21:17 +02:00

78 lines
3.0 KiB
Go

package transcribe
import (
"context"
"errors"
"fmt"
"git.d-ma.be/mathias/tapir/internal/domain"
"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}
}
// Transcribe implements uploads.Transcriber.
func (e *Ensemble) Transcribe(ctx context.Context, wavPath string) (domain.Transcription, error) {
b, berr := e.base.Transcribe(ctx, wavPath)
if err := ctx.Err(); err != nil {
return domain.Transcription{}, err
}
a, aerr := e.alt.Transcribe(ctx, 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)
return out, nil
case berr == nil:
return e.single(b, e.base.Name(), fmt.Sprintf("%s failed: %v", e.alt.Name(), aerr)), nil
case aerr == nil:
return e.single(a, e.alt.Name(), fmt.Sprintf("%s failed: %v", e.base.Name(), 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.fallback.Transcribe(ctx, 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
}
// 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
}