Files
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

132 lines
4.7 KiB
Go

package reconcile_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/reconcile"
)
func disputes(base, alt string) []reconcile.Dispute {
return reconcile.FindDisputes(reconcile.Tokenize(base), reconcile.Tokenize(alt))
}
func TestFindDisputes_NoneWhenTheTextsAgreeWordForWord(t *testing.T) {
assert.Empty(t, disputes("Vi går igenom läget.", "vi går igenom läget"),
"casing and punctuation are not disagreements")
}
func TestFindDisputes_OneReplacedWordIsOneDisputeCarryingBothReadings(t *testing.T) {
got := disputes("Vi ska målade grannarna.", "Vi ska mölade grannarna.")
require.Len(t, got, 1)
d := got[0]
assert.Equal(t, "målade", d.Base)
assert.Equal(t, "mölade", d.Alt)
assert.Equal(t, 2, d.BaseStart)
assert.Equal(t, 3, d.BaseEnd)
assert.Equal(t, 2, d.AltStart)
assert.Equal(t, 3, d.AltEnd)
}
func TestFindDisputes_ReadingsKeepTheirOwnSpellingAndPunctuation(t *testing.T) {
got := disputes("Det blir A+2, alltså.", "Det blir A 2, alltså.")
require.Len(t, got, 1)
assert.Equal(t, "A+2,", got[0].Base)
assert.Equal(t, "A 2,", got[0].Alt, "the alternative's words replace the span as the engine wrote them")
}
func TestFindDisputes_AWordOnlyOneEngineHeardHasAnEmptyOtherSide(t *testing.T) {
got := disputes("Man får tillbaka ett förslag.", "Man får tillbaka ett coolt förslag.")
require.Len(t, got, 1)
assert.Equal(t, "", got[0].Base)
assert.Equal(t, "coolt", got[0].Alt)
assert.Equal(t, got[0].BaseStart, got[0].BaseEnd, "an insertion has an empty base range at its position")
assert.Equal(t, 4, got[0].BaseStart)
}
func TestFindDisputes_TwoDifferencesSeparatedByOneSharedWordAreOneDispute(t *testing.T) {
// "x ... y" around a single agreed word is one decision for the reader, not two.
got := disputes("a b c d e", "a x c y e")
require.Len(t, got, 1)
assert.Equal(t, "b c d", got[0].Base)
assert.Equal(t, "x c y", got[0].Alt)
}
func TestFindDisputes_TwoSharedWordsKeepTheDisputesApart(t *testing.T) {
got := disputes("a b c d e f", "a x c d y f")
require.Len(t, got, 2)
assert.Equal(t, "b", got[0].Base)
assert.Equal(t, "e", got[1].Base)
}
func TestFindDisputes_ChainsOfCloseDifferencesMergeIntoOne(t *testing.T) {
got := disputes("a b c d e f g", "a x c y e z g")
require.Len(t, got, 1)
assert.Equal(t, "b c d e f", got[0].Base)
}
func TestFindDisputes_LeavesIndicesThatAddressBothTokenSlices(t *testing.T) {
base := reconcile.Tokenize("Hej Anders och Maria")
alt := reconcile.Tokenize("Hej Sanders och Maria")
got := reconcile.FindDisputes(base, alt)
require.Len(t, got, 1)
assert.Equal(t, "Anders", base[got[0].BaseStart].Text)
assert.Equal(t, "Sanders", alt[got[0].AltStart].Text)
}
func TestAutoResolve(t *testing.T) {
tests := []struct {
name string
base, alt string
resolved bool
}{
{"a compound written as two words or one", "i takrenns delen", "i takrennsdelen", true},
{"a compound with a hyphen", "takrenns-delen", "takrenns delen", true},
{"a filler only the alternative heard", "vi ska ju betala", "vi ska betala", true},
{"a filler only the base heard", "vi ska betala", "vi ska ju betala", true},
{"two fillers in a row", "vi ska ja ju betala", "vi ska betala", true},
{"a different word is for the model", "vi ska målade", "vi ska mölade", false},
{"a different number is for the model", "totalt 1050 kronor", "totalt 2050 kronor", false},
{"a content word only one side heard is for the model", "ett förslag", "ett coolt förslag", false},
{"three filler words are too many to wave through", "vi ska ju då liksom betala", "vi ska betala", false},
{"a filler swapped for a content word is for the model", "vi ska ju betala", "vi ska nu betala", false},
{"nej is an answer, not a filler", "men nej det går inte", "men det går inte", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := disputes(tt.base, tt.alt)
require.Len(t, got, 1, "the fixture must be exactly one dispute")
assert.Equal(t, tt.resolved, reconcile.AutoResolve(got[0]))
})
}
}
func TestContextAround_TakesWordsFromTheBaseOnBothSidesAndStopsAtTheEdges(t *testing.T) {
base := reconcile.Tokenize("ett två tre fyra fem sex sju åtta nio tio")
d := reconcile.Dispute{BaseStart: 4, BaseEnd: 6} // "fem sex"
before, after := reconcile.ContextAround(base, d, 3)
assert.Equal(t, "två tre fyra", before)
assert.Equal(t, "sju åtta nio", after)
b2, a2 := reconcile.ContextAround(base, reconcile.Dispute{BaseStart: 0, BaseEnd: 1}, 3)
assert.Equal(t, "", b2)
assert.Equal(t, "två tre fyra", a2)
b3, a3 := reconcile.ContextAround(base, reconcile.Dispute{BaseStart: 9, BaseEnd: 10}, 3)
assert.Equal(t, "sju åtta nio", b3)
assert.Equal(t, "", a3)
}