diff --git a/internal/adapters/transcribe/ensemble.go b/internal/adapters/transcribe/ensemble.go new file mode 100644 index 0000000..c71ff01 --- /dev/null +++ b/internal/adapters/transcribe/ensemble.go @@ -0,0 +1,77 @@ +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 +}