Files
tapir/internal/reconcile/adjudicate.go
mathias 4a2067063d
CI / Deploy via GitOps (push) Successful in 7s
CI / Lint / Test / Vet (push) Successful in 33s
CI / Build & Import (push) Successful in 23s
feat: upload audio, get a private Swedish transcript, discuss it (#33, ADR-033) (#39)
Upload a recording (audio, or video with the camera off), get a private Swedish transcript, and chat about it.

- ADR-033: RLS-scoped private transcript store; ffprobe/ffmpeg intake; gateway transcription (iguana kb-whisper, berget fallback); in-process single-replica worker; chat over the transcript (local models only).
- ADR-034: optional two-engine mode (pianissimo + kb-whisper) reconciled by a LOCAL model; off unless TAPIR_RECONCILE_BASE_MODEL is set.
- ADR-035: real progress (phases, running/quiet/lost), phase logging, every judged spot stored, merged segments.
- Hardening from four adversarial reviews: errors logged and stored as kinds never text; per-engine deadlines; converted-length check; bounded queue; client hang-ups are not errors; strict local-alias check.
- Dockerfile: static ffmpeg/ffprobe 7.0.2 pinned by sha256 (#34). Built and inspected by hand; CI does not build the image on a PR, so its first CI build is this merge.

Uploads stay OFF until TAPIR_UPLOADS_ENABLED is set in the deployment.

Closes #33, #34, #42, #43, #44.

Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-03 14:13:37 +00:00

120 lines
3.9 KiB
Go

package reconcile
import (
"context"
"fmt"
"regexp"
"strings"
)
// Verdict is an adjudicator's decision about one dispute. The zero value is
// VerdictUnsure: with no decision the base reading stands and the spot is flagged.
type Verdict int
const (
VerdictUnsure Verdict = iota
VerdictBase
VerdictAlt
)
// Request is one dispute as the adjudicator sees it: the surrounding words, the two
// readings, and which of them is shown as "Reading 1". Alternating AltFirst between
// disputes averages out position bias.
type Request struct {
Before, After string
Base, Alt string
AltFirst bool
}
// Adjudicator decides which reading of a dispute the speaker most likely said.
type Adjudicator interface {
Adjudicate(ctx context.Context, r Request) (Verdict, error)
}
// Completer is the chat surface the LLM adjudicator needs. *llm.Client satisfies it.
type Completer interface {
Complete(ctx context.Context, system, user string) (string, error)
}
// systemPrompt is the wording that was MEASURED (docs/research/
// local-model-adjudicator-sweep-2026-10-01.md): gemma4-26b 92% and gemma4-31b 97% against
// the speaker's blind answers. Changing it invalidates those figures.
const systemPrompt = "You compare two automatic transcriptions of the same Swedish speech. A single person is dictating project notes " +
"(finance and payments jargon, with English terms mixed into Swedish). The two transcriptions differ at ONE marked spot. " +
"Decide which reading is what the speaker most likely actually said, using the surrounding text and ordinary Swedish. " +
"Reply with exactly one line, either 'ANSWER: 1', 'ANSWER: 2' or 'ANSWER: UNSURE', then one short sentence of reasoning."
var answerRe = regexp.MustCompile(`(?i)ANSWER:\s*(1|2|UNSURE)\b`)
// LLMAdjudicator asks a chat model. It never invents an answer: a reply it cannot read
// is VerdictUnsure, and a transport error is returned as an error so the caller can tell
// "the model is down" from "the model is unsure".
type LLMAdjudicator struct{ c Completer }
// NewLLMAdjudicator wraps a Completer.
func NewLLMAdjudicator(c Completer) *LLMAdjudicator { return &LLMAdjudicator{c: c} }
// Adjudicate implements Adjudicator.
func (a *LLMAdjudicator) Adjudicate(ctx context.Context, r Request) (Verdict, error) {
first, second := r.Base, r.Alt
if r.AltFirst {
first, second = r.Alt, r.Base
}
user := fmt.Sprintf("Text before the spot:\n%s\n\n[[ THE SPOT ]]\n\nText after the spot:\n%s\n\nReading 1: %s\nReading 2: %s\n",
r.Before, r.After, describe(first), describe(second))
reply, err := a.c.Complete(ctx, systemPrompt, user)
if err != nil {
return VerdictUnsure, fmt.Errorf("reconcile: adjudicate: %w", err)
}
m := answerRe.FindStringSubmatch(reply)
if m == nil {
return VerdictUnsure, nil
}
switch strings.ToUpper(m[1]) {
case "1":
if r.AltFirst {
return VerdictAlt, nil
}
return VerdictBase, nil
case "2":
if r.AltFirst {
return VerdictBase, nil
}
return VerdictAlt, nil
}
return VerdictUnsure, nil
}
func describe(reading string) string {
if strings.TrimSpace(reading) == "" {
return "(nothing: the words are simply absent)"
}
return reading
}
// OrderChecked asks the inner adjudicator twice with the reading order swapped and
// keeps a verdict only when both agree. A model that flips with the order is showing
// position bias, not judgement, so disagreement is VerdictUnsure. It doubles the time.
type OrderChecked struct{ inner Adjudicator }
// NewOrderChecked wraps an adjudicator.
func NewOrderChecked(inner Adjudicator) *OrderChecked { return &OrderChecked{inner: inner} }
// Adjudicate implements Adjudicator.
func (o *OrderChecked) Adjudicate(ctx context.Context, r Request) (Verdict, error) {
r.AltFirst = false
v1, err := o.inner.Adjudicate(ctx, r)
if err != nil {
return VerdictUnsure, err
}
r.AltFirst = true
v2, err := o.inner.Adjudicate(ctx, r)
if err != nil {
return VerdictUnsure, err
}
if v1 == v2 {
return v1, nil
}
return VerdictUnsure, nil
}