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>
This commit was merged in pull request #39.
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user