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 }