diff --git a/internal/reconcile/reconcile.go b/internal/reconcile/reconcile.go new file mode 100644 index 0000000..ba604a7 --- /dev/null +++ b/internal/reconcile/reconcile.go @@ -0,0 +1,152 @@ +package reconcile + +import ( + "context" + "fmt" + "strings" + + "git.d-ma.be/mathias/tapir/internal/domain" +) + +// MaxContested bounds the stored list of contested spots. The counts on the +// Reconciliation are always exact; only this list is capped. +const MaxContested = 200 + +// Options configure Reconcile. Zero Window and BreakAfter take their defaults. +type Options struct { + // Window is how many words of the base to show on each side of a dispute. 100 is + // what was measured (docs/research/local-model-adjudicator-sweep-2026-10-01.md). + Window int + // BreakAfter is how many consecutive model errors stop the reconciliation, so a + // model that is down costs three timeouts, not one per dispute. + BreakAfter int + // BaseEngine, AltEngine and Reconciler are recorded on the result. + BaseEngine, AltEngine, Reconciler string +} + +// Reconcile merges two transcripts of the same speech. The base text stands except +// where the model chooses the alternative's reading of a dispute; trivial disputes +// (a compound split, a filler word) are resolved without the model. It never fails a +// transcription: if the model cannot be used the base text comes back unreconciled +// and the result says why. The only error it returns is a cancelled context, which +// is the job stopping, not a verdict on the model. +func Reconcile(ctx context.Context, base, alt domain.Transcription, adj Adjudicator, o Options) (domain.Transcription, error) { + window, breakAfter := o.Window, o.BreakAfter + if window <= 0 { + window = 100 + } + if breakAfter <= 0 { + breakAfter = 3 + } + bt, at := Tokenize(base.Text), Tokenize(alt.Text) + ds := FindDisputes(bt, at) + rec := &domain.Reconciliation{ + Status: domain.ReconcileDone, Base: o.BaseEngine, Alt: o.AltEngine, Reconciler: o.Reconciler, Disputes: len(ds), + } + seconds := positioner(base, len(bt)) + + verdicts := make([]Verdict, len(ds)) + consecutive := 0 + for i, d := range ds { + if AutoResolve(d) { + rec.AutoResolved++ + verdicts[i] = VerdictBase + continue + } + if err := ctx.Err(); err != nil { + return domain.Transcription{}, err + } + before, after := ContextAround(bt, d, window) + v, err := adj.Adjudicate(ctx, Request{Before: before, After: after, Base: d.Base, Alt: d.Alt, AltFirst: i%2 == 1}) + if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return domain.Transcription{}, ctxErr + } + rec.Errors++ + consecutive++ + if consecutive >= breakAfter { + return skipped(base, rec, fmt.Sprintf("the model failed %d times in a row: %v", consecutive, err)), nil + } + v = VerdictUnsure + } else { + consecutive = 0 + rec.Adjudicated++ + } + verdicts[i] = v + switch v { + case VerdictAlt: + rec.Changed++ + addContested(rec, domain.ContestedSpot{Seconds: seconds(d.BaseStart), Base: d.Base, Alt: d.Alt, Used: "alt"}) + case VerdictUnsure: + rec.Unsure++ + addContested(rec, domain.ContestedSpot{Seconds: seconds(d.BaseStart), Base: d.Base, Alt: d.Alt, Used: "base", Unsure: true}) + } + } + + var parts []string + pos := 0 + for i, d := range ds { + parts = appendTexts(parts, bt[pos:d.BaseStart]) + if verdicts[i] == VerdictAlt { + parts = appendTexts(parts, at[d.AltStart:d.AltEnd]) + } else { + parts = appendTexts(parts, bt[d.BaseStart:d.BaseEnd]) + } + pos = d.BaseEnd + } + parts = appendTexts(parts, bt[pos:]) + + out := base + out.Text = strings.Join(parts, " ") + out.Reconciliation = rec + return out, nil +} + +// skipped is the base text, none of the partial changes, and the reason. +func skipped(base domain.Transcription, rec *domain.Reconciliation, reason string) domain.Transcription { + out := base + out.Reconciliation = &domain.Reconciliation{ + Status: domain.ReconcileSkipped, Reason: reason, Base: rec.Base, Alt: rec.Alt, Reconciler: rec.Reconciler, + Disputes: rec.Disputes, AutoResolved: rec.AutoResolved, Errors: rec.Errors, + } + return out +} + +func appendTexts(dst []string, ts []Token) []string { + for _, t := range ts { + dst = append(dst, t.Text) + } + return dst +} + +// positioner maps a token index of the base to the second it starts at: through the +// segments when there are any, proportionally otherwise. +func positioner(base domain.Transcription, tokens int) func(int) float64 { + if len(base.Segments) == 0 { + return func(i int) float64 { + if tokens == 0 { + return 0 + } + return float64(i) / float64(tokens) * base.AudioSeconds + } + } + starts := make([]int, len(base.Segments)) + n := 0 + for i, s := range base.Segments { + starts[i] = n + n += len(Tokenize(s.Text)) + } + return func(i int) float64 { + k := 0 + for k+1 < len(starts) && starts[k+1] <= i { + k++ + } + return base.Segments[k].Start + } +} + +func addContested(rec *domain.Reconciliation, c domain.ContestedSpot) { + if len(rec.Contested) < MaxContested { + rec.Contested = append(rec.Contested, c) + } +}