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>
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user