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>
184 lines
7.0 KiB
Go
184 lines
7.0 KiB
Go
package web_test
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"git.d-ma.be/mathias/tapir/internal/adapters/chat"
|
|
"git.d-ma.be/mathias/tapir/internal/web"
|
|
)
|
|
|
|
// Chat over an uploaded transcript REUSES the ADR-027 chat path (the same
|
|
// Chatter, the same panel) — only the lookup differs: the row comes from the
|
|
// user-scoped private store, so another user's upload is a 404 and, crucially, no
|
|
// model is ever called with it. Transcript text is synthetic; this repo is public.
|
|
|
|
const meetingText = "Anders ansvarar för virket. Maria undersöker leverantörer."
|
|
|
|
func newUploadChatApp(t *testing.T) (*web.App, *fakeChatter) {
|
|
t.Helper()
|
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
|
fc := &fakeChatter{
|
|
models: []string{"koala/phi4-mini", "iguana/gemma4-26b"},
|
|
reply: chat.Reply{Answer: "Anders owns the timber order."},
|
|
}
|
|
app.Chat = fc
|
|
app.UploadChatMaxChars = 120000
|
|
app.UploadChatModels = fc.models
|
|
return app, fc
|
|
}
|
|
|
|
func chatPost(path string, kv url.Values) *http.Request {
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(kv.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
return req
|
|
}
|
|
|
|
func TestUploadChatPage_OwnerOfAFinishedUploadSeesThePanelPostingToTheUploadRoute(t *testing.T) {
|
|
app, _ := newUploadChatApp(t)
|
|
id := seedUpload(t, userID, "Roadmap sync", "done", meetingText)
|
|
|
|
rec := do(t, app, get("/uploads/"+id+"/chat"))
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
b := body(t, rec)
|
|
assert.Contains(t, b, `action="/uploads/`+id+`/chat"`, "the form posts to the UPLOAD route, not /v/{id}/chat")
|
|
assert.Contains(t, b, "Roadmap sync")
|
|
assert.Contains(t, b, "koala/phi4-mini")
|
|
assert.Contains(t, b, "recording", "wording says recording, not video")
|
|
}
|
|
|
|
func TestUploadChatPage_AnUnfinishedUploadRedirectsToItsStatusPage(t *testing.T) {
|
|
app, fc := newUploadChatApp(t)
|
|
id := seedUpload(t, userID, "m", "transcribing", "")
|
|
|
|
rec := do(t, app, get("/uploads/"+id+"/chat"))
|
|
|
|
assert.Equal(t, http.StatusSeeOther, rec.Code)
|
|
assert.Equal(t, "/uploads/"+id, rec.Header().Get("Location"))
|
|
assert.Zero(t, fc.callCount)
|
|
}
|
|
|
|
func TestUploadChat_AnotherUsersUploadIs404AndNoModelIsCalled(t *testing.T) {
|
|
app, fc := newUploadChatApp(t)
|
|
theirs := seedUpload(t, otherUserID, "Their private meeting", "done", meetingText)
|
|
|
|
getRec := do(t, app, get("/uploads/"+theirs+"/chat"))
|
|
postRec := do(t, app, chatPost("/uploads/"+theirs+"/chat", url.Values{"question": {"what did they say?"}}))
|
|
|
|
assert.Equal(t, http.StatusNotFound, getRec.Code)
|
|
assert.Equal(t, http.StatusNotFound, postRec.Code)
|
|
assert.NotContains(t, body(t, getRec), "Their private meeting")
|
|
assert.Zero(t, fc.callCount, "someone else's transcript must never reach a model")
|
|
}
|
|
|
|
func TestUploadChatPost_SendsTheOwnersTranscriptWithTheUploadBudget(t *testing.T) {
|
|
app, fc := newUploadChatApp(t)
|
|
app.UploadChatMaxChars = 77777
|
|
id := seedUpload(t, userID, "Roadmap sync", "done", meetingText)
|
|
|
|
rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{
|
|
"question": {"Who owns the timber order?"},
|
|
"model": {"iguana/gemma4-26b"},
|
|
}))
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
assert.Equal(t, 1, fc.callCount)
|
|
assert.Equal(t, meetingText, fc.gotReq.Transcript)
|
|
assert.Equal(t, "Who owns the timber order?", fc.gotReq.Question)
|
|
assert.Equal(t, "iguana/gemma4-26b", fc.gotReq.Model)
|
|
assert.Equal(t, 77777, fc.gotReq.MaxChars,
|
|
"an uploaded transcript takes its own budget: the 18,000-char caption default would cut an hour to its first third")
|
|
assert.Contains(t, body(t, rec), "Anders owns the timber order.")
|
|
}
|
|
|
|
func TestUploadChatPost_AnUnofferedModelFallsBackToTheDefault(t *testing.T) {
|
|
app, fc := newUploadChatApp(t)
|
|
id := seedUpload(t, userID, "m", "done", meetingText)
|
|
|
|
do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}, "model": {"evil/model"}}))
|
|
|
|
assert.Equal(t, "koala/phi4-mini", fc.gotReq.Model)
|
|
}
|
|
|
|
func TestUploadChatPost_UnfinishedUploadCallsNoModel(t *testing.T) {
|
|
app, fc := newUploadChatApp(t)
|
|
id := seedUpload(t, userID, "m", "queued", "")
|
|
|
|
rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}}))
|
|
|
|
assert.Equal(t, http.StatusSeeOther, rec.Code)
|
|
assert.Zero(t, fc.callCount)
|
|
}
|
|
|
|
func TestUploadChatPost_PriorTurnsRideAlongAndATruncatedReplyIsSaidSo(t *testing.T) {
|
|
app, fc := newUploadChatApp(t)
|
|
fc.reply = chat.Reply{Answer: "second answer", Truncated: true}
|
|
id := seedUpload(t, userID, "m", "done", meetingText)
|
|
|
|
rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{
|
|
"question": {"and who else?"},
|
|
"hq": {"first question"},
|
|
"ha": {"first answer"},
|
|
}))
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
assert.Equal(t, []chat.Turn{{Question: "first question", Answer: "first answer"}}, fc.gotReq.History)
|
|
b := body(t, rec)
|
|
assert.Contains(t, b, "first answer")
|
|
assert.Contains(t, b, "second answer")
|
|
assert.Contains(t, b, "end of the recording", "a long recording cut to a budget must say so, in its own words")
|
|
}
|
|
|
|
func TestUploadChatPost_AModelFailureIsShownInlineNotAs500(t *testing.T) {
|
|
app, fc := newUploadChatApp(t)
|
|
fc.err = errors.New("gateway down")
|
|
id := seedUpload(t, userID, "m", "done", meetingText)
|
|
|
|
rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}, "hq": {"earlier"}, "ha": {"kept"}}))
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
b := body(t, rec)
|
|
assert.Contains(t, b, "answer just now", "the failure is inline (the apostrophe is HTML-escaped, so match the tail)")
|
|
assert.Contains(t, b, "kept", "the conversation survives a failed turn")
|
|
}
|
|
|
|
func TestUploadChatRoutes_AreNotMountedWithoutAChatBackend(t *testing.T) {
|
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
|
id := seedUpload(t, userID, "m", "done", meetingText)
|
|
|
|
assert.Equal(t, http.StatusNotFound, do(t, app, get("/uploads/"+id+"/chat")).Code)
|
|
}
|
|
|
|
func TestUploadStatus_DoneOffersTheChatLinkOnlyWhenChatIsWired(t *testing.T) {
|
|
appWith, _ := newUploadChatApp(t)
|
|
id := seedUpload(t, userID, "m", "done", meetingText)
|
|
assert.Contains(t, body(t, do(t, appWith, get("/uploads/"+id+"/status"))), `href="/uploads/`+id+`/chat"`)
|
|
|
|
appWithout, _ := newUploadApp(t, audioOK, 200<<20)
|
|
id2 := seedUpload(t, userID, "m2", "done", meetingText)
|
|
assert.NotContains(t, body(t, do(t, appWithout, get("/uploads/"+id2+"/status"))), "/chat")
|
|
}
|
|
|
|
func TestUploadChat_OpensWithTheUploadChatModelNotTheSummarizerDefault(t *testing.T) {
|
|
// koala/phi4-mini is first in the offered list (it is the summarizer default) and
|
|
// has an 8,192-token context: an hour of speech returns HTTP 400 there. Chat over
|
|
// an upload must open with the model configured for it.
|
|
app, fc := newUploadChatApp(t)
|
|
app.UploadChatModel = "iguana/gemma4-26b"
|
|
id := seedUpload(t, userID, "m", "done", meetingText)
|
|
|
|
page := body(t, do(t, app, get("/uploads/"+id+"/chat")))
|
|
do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}}))
|
|
|
|
assert.Contains(t, page, `<option value="iguana/gemma4-26b" selected`, "the page opens on the upload chat model")
|
|
assert.Equal(t, "iguana/gemma4-26b", fc.gotReq.Model, "a post that names no model uses it too")
|
|
}
|