feat(uploads): choose the language of a recording; record it, show it, carry it to the engines (#61)
Form field (Swedish preselected, Other = auto) read before the file part and refused before anything is spooled if outside the closed set; stored on the row (migration 021, CHECK constraint, default sv for history); carried on the Job and the worker's context; shown on the status page. Feature scenarios and the coverage map are updated, and ADR-033/034 amended. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+31
-2
@@ -28,7 +28,7 @@ import (
|
||||
// *uploads.Intake satisfies it. It is the whole of what the handlers know about
|
||||
// validation, spooling and queueing.
|
||||
type Uploader interface {
|
||||
Accept(ctx context.Context, userID, title string, src io.Reader) (id string, err error)
|
||||
Accept(ctx context.Context, userID, title string, lang domain.Language, src io.Reader) (id string, err error)
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -36,6 +36,8 @@ const (
|
||||
// and the title field; the cap itself is enforced on the file part by Intake.
|
||||
uploadOverhead = 1 << 20
|
||||
maxTitleRunes = 200
|
||||
// maxLanguageBytes bounds the language field: the longest value is four bytes.
|
||||
maxLanguageBytes = 16
|
||||
// uploadPollEvery is how often an unfinished upload re-asks for its status.
|
||||
uploadPollEvery = "2s"
|
||||
// serviceSilentAfter is how long a job may go without any signal (heartbeat or
|
||||
@@ -89,6 +91,7 @@ func (a *App) handleUploadPost(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
title := ""
|
||||
lang := domain.LangSwedish
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if errors.Is(err, io.EOF) {
|
||||
@@ -121,11 +124,29 @@ func (a *App) handleUploadPost(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
title = cleanTitle(string(b))
|
||||
case "language":
|
||||
// A closed set read before the file: anything else is refused before the file
|
||||
// is spooled, so a crafted value never reaches an engine (tapir#61).
|
||||
b, err := io.ReadAll(io.LimitReader(part, maxLanguageBytes+1))
|
||||
if err != nil {
|
||||
if a.abandoned(w, r) {
|
||||
return
|
||||
}
|
||||
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "That upload could not be read. Please try again.")
|
||||
return
|
||||
}
|
||||
l, ok := domain.ParseLanguage(string(b))
|
||||
if !ok || len(b) > maxLanguageBytes {
|
||||
a.logger().Info("upload rejected", "reason", "language")
|
||||
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "Choose a language from the list.")
|
||||
return
|
||||
}
|
||||
lang = l
|
||||
case "file":
|
||||
if title == "" {
|
||||
title = titleFromFilename(part.FileName())
|
||||
}
|
||||
id, err := a.Uploads.Accept(r.Context(), userID, title, part)
|
||||
id, err := a.Uploads.Accept(r.Context(), userID, title, lang, part)
|
||||
if err != nil {
|
||||
a.uploadFailure(w, r, userID, err)
|
||||
return
|
||||
@@ -300,6 +321,14 @@ func cloudProvider(endpoint string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// languageLabel is how the page names the language a recording was transcribed as.
|
||||
func languageLabel(l domain.Language) string {
|
||||
if l == domain.LangAuto {
|
||||
return "Other (detected by the engine)"
|
||||
}
|
||||
return "Swedish"
|
||||
}
|
||||
|
||||
// hasNote reports whether any phase carries a note. A note is the only warning for a
|
||||
// recording that is shorter than its file claimed, so a record that has one is shown open.
|
||||
func hasNote(steps []domain.ProgressStep) bool {
|
||||
|
||||
@@ -24,6 +24,14 @@ templ UploadsPage(v uploadsView) {
|
||||
}
|
||||
<form method="post" action="/uploads" enctype="multipart/form-data">
|
||||
<p><label>Title (optional) <input type="text" name="title" maxlength="200"/></label></p>
|
||||
<p>
|
||||
<label>Language
|
||||
<select name="language">
|
||||
<option value="sv" selected>Swedish</option>
|
||||
<option value="auto">Other (detected by the engine)</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
<p><input type="file" name="file" accept="audio/*,.m4a,.mp3,.wav,.opus,.ogg,.mp4" required/></p>
|
||||
<p><button type="submit">Upload and transcribe</button></p>
|
||||
</form>
|
||||
@@ -66,6 +74,7 @@ templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
|
||||
Transcribed { uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))) }
|
||||
of audio by <span class="chip">{ u.Endpoint }</span>.
|
||||
</p>
|
||||
<p>Language: { languageLabel(u.Language) }</p>
|
||||
if provider, cloud := cloudProvider(u.Endpoint); cloud {
|
||||
<p class="cloud-note">
|
||||
This recording's audio left the homelab: it was transcribed by a cloud service ({ provider })
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||
)
|
||||
|
||||
// A client that hangs up in the middle of an upload is routine, not a server fault. The
|
||||
@@ -85,7 +87,7 @@ func TestUploadPost_AClientThatHangsUpAnywhereInTheBodyIsLoggedAtInfoAndNeverAsA
|
||||
// context is cancelled, then Accept fails the way a read from a dead connection does.
|
||||
type goneMidAccept struct{ cancel context.CancelFunc }
|
||||
|
||||
func (g goneMidAccept) Accept(context.Context, string, string, io.Reader) (string, error) {
|
||||
func (g goneMidAccept) Accept(context.Context, string, string, domain.Language, io.Reader) (string, error) {
|
||||
g.cancel()
|
||||
return "", fmt.Errorf("receive: %w", io.ErrClosedPipe)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||
"git.d-ma.be/mathias/tapir/internal/uploads"
|
||||
"git.d-ma.be/mathias/tapir/internal/web"
|
||||
)
|
||||
@@ -132,7 +133,7 @@ func TestUploadPost_AcceptedAudioQueuesARowAndRedirectsToIt(t *testing.T) {
|
||||
assert.Equal(t, "Sprint review", title)
|
||||
assert.Equal(t, "/uploads/"+id, rec.Header().Get("Location"))
|
||||
require.Len(t, sub.jobs, 1)
|
||||
assert.Equal(t, uploads.Job{UserID: userID, UploadID: id, Path: sub.jobs[0].Path, Duration: audioOK.Duration}, sub.jobs[0])
|
||||
assert.Equal(t, uploads.Job{UserID: userID, UploadID: id, Path: sub.jobs[0].Path, Language: domain.LangSwedish, Duration: audioOK.Duration}, sub.jobs[0])
|
||||
spooled, err := os.ReadFile(sub.jobs[0].Path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "synthetic audio bytes", string(spooled))
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package web_test
|
||||
|
||||
// The language of a recording is chosen on the upload form (tapir#61). These drive the real
|
||||
// Router, Intake and Postgres store; the submitter is the process boundary and records the
|
||||
// job the worker would receive.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||
)
|
||||
|
||||
// langReq builds an upload whose parts come in the order given: "title", "language" and
|
||||
// "file" (the file part always carries the same synthetic bytes).
|
||||
func langReq(t *testing.T, order []string, language string) *http.Request {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
for _, part := range order {
|
||||
switch part {
|
||||
case "title":
|
||||
require.NoError(t, mw.WriteField("title", "m"))
|
||||
case "language":
|
||||
require.NoError(t, mw.WriteField("language", language))
|
||||
case "file":
|
||||
fw, err := mw.CreateFormFile("file", "m.wav")
|
||||
require.NoError(t, err)
|
||||
_, err = fw.Write([]byte("RIFF....WAVE"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
require.NoError(t, mw.Close())
|
||||
req := httptest.NewRequest(http.MethodPost, "/uploads", &buf)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
return req
|
||||
}
|
||||
|
||||
func rowLanguage(t *testing.T, location string) domain.Language {
|
||||
t.Helper()
|
||||
id := strings.TrimPrefix(location, "/uploads/")
|
||||
got, err := newStore(t).GetUpload(context.Background(), userID, id)
|
||||
require.NoError(t, err)
|
||||
return got.Language
|
||||
}
|
||||
|
||||
func TestUploadPost_TheLanguageFieldReachesTheJobAndTheRow(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
language string
|
||||
want domain.Language
|
||||
}{
|
||||
{"other", "auto", domain.LangAuto},
|
||||
{"swedish", "sv", domain.LangSwedish},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
app, sub := newUploadApp(t, audioOK, 200<<20)
|
||||
|
||||
rec := do(t, app, langReq(t, []string{"title", "language", "file"}, tt.language))
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, rec.Code)
|
||||
require.Len(t, sub.jobs, 1)
|
||||
assert.Equal(t, tt.want, sub.jobs[0].Language)
|
||||
assert.Equal(t, tt.want, rowLanguage(t, rec.Header().Get("Location")))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadPost_NoLanguageFieldIsSwedishAsBefore(t *testing.T) {
|
||||
app, sub := newUploadApp(t, audioOK, 200<<20)
|
||||
|
||||
rec := do(t, app, langReq(t, []string{"title", "file"}, ""))
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, rec.Code)
|
||||
require.Len(t, sub.jobs, 1)
|
||||
assert.Equal(t, domain.LangSwedish, sub.jobs[0].Language)
|
||||
assert.Equal(t, domain.LangSwedish, rowLanguage(t, rec.Header().Get("Location")))
|
||||
}
|
||||
|
||||
// A part after the file is never read (the file part ends the stream), so a client that
|
||||
// orders its fields wrongly gets Swedish, the old behaviour, not a guess.
|
||||
func TestUploadPost_ALanguageFieldAfterTheFileIsNotReadAndTheUploadIsSwedish(t *testing.T) {
|
||||
app, sub := newUploadApp(t, audioOK, 200<<20)
|
||||
|
||||
rec := do(t, app, langReq(t, []string{"title", "file", "language"}, "auto"))
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, rec.Code)
|
||||
require.Len(t, sub.jobs, 1)
|
||||
assert.Equal(t, domain.LangSwedish, sub.jobs[0].Language)
|
||||
}
|
||||
|
||||
func TestUploadPost_ALanguageOutsideTheClosedSetIsRefusedBeforeAnythingIsSpooledOrStored(t *testing.T) {
|
||||
for _, bad := range []string{"en", "SV", " sv", "sv,auto", "../auto", strings.Repeat("a", 5000)} {
|
||||
t.Run(bad[:min(len(bad), 12)], func(t *testing.T) {
|
||||
app, sub := newUploadApp(t, audioOK, 200<<20)
|
||||
|
||||
rec := do(t, app, langReq(t, []string{"title", "language", "file"}, bad))
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
assert.Empty(t, sub.jobs, "a refused post must not enqueue transcription")
|
||||
rows, err := newStore(t).ListUploads(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, rows, "and must leave no row")
|
||||
assert.Contains(t, body(t, rec), "language")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadPage_TheFormOffersSwedishPreselectedAndOtherBeforeTheFilePart(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
|
||||
b := body(t, do(t, app, get("/uploads")))
|
||||
|
||||
sel := strings.Index(b, `name="language"`)
|
||||
file := strings.Index(b, `name="file"`)
|
||||
require.True(t, sel >= 0, "the form has a language field")
|
||||
assert.True(t, sel < file, "the field must come before the file part: the stream ends at the file")
|
||||
assert.Regexp(t, `<option value="sv" selected`, b, "Swedish is preselected: today's behaviour")
|
||||
assert.Contains(t, b, `<option value="auto"`)
|
||||
}
|
||||
|
||||
func TestUploadStatus_NamesTheLanguageTheRecordingWasTranscribedAs(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
language domain.Language
|
||||
want string
|
||||
}{
|
||||
{domain.LangSwedish, "Swedish"},
|
||||
{domain.LangAuto, "Other (detected by the engine)"},
|
||||
} {
|
||||
t.Run(string(tt.language), func(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
|
||||
_, err := rawPool(t).Exec(context.Background(), `UPDATE uploaded_transcripts SET language = $2 WHERE id = $1`, id, string(tt.language))
|
||||
require.NoError(t, err)
|
||||
|
||||
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
|
||||
|
||||
assert.Contains(t, b, "Language: "+tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||
)
|
||||
|
||||
// Error paths of the upload routes log a kind, never the error's text, which can carry
|
||||
@@ -37,7 +39,7 @@ func (l *lockedBuf) String() string {
|
||||
|
||||
type failingUploader struct{ err error }
|
||||
|
||||
func (f failingUploader) Accept(context.Context, string, string, io.Reader) (string, error) {
|
||||
func (f failingUploader) Accept(context.Context, string, string, domain.Language, io.Reader) (string, error) {
|
||||
return "", f.err
|
||||
}
|
||||
|
||||
|
||||
+319
-306
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user