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,51 @@
|
||||
// Package errkind reduces an error to a short kind that is safe to log, store and show.
|
||||
//
|
||||
// An error's own text can hold a provider's response body (a gateway that echoes the
|
||||
// prompt), a file path (ffmpeg names the spool file), or a word from a private
|
||||
// transcript. None of that may reach a log line, a database column or a page about a
|
||||
// private recording (ADR-033: "typed, never a provider's string"; ADR-035). Of returns
|
||||
// only a fixed vocabulary, so it can never carry any.
|
||||
package errkind
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
)
|
||||
|
||||
// HTTPStatuser is implemented by errors that are a non-2xx answer from a service.
|
||||
type HTTPStatuser interface{ HTTPStatus() int }
|
||||
|
||||
// SQLStater is implemented by database errors that carry a SQLSTATE (pgx's PgError does).
|
||||
// The five-character code is a fixed vocabulary; the error's message is not: Postgres quotes
|
||||
// the offending value, which for a transcript row is transcript text.
|
||||
type SQLStater interface{ SQLState() string }
|
||||
|
||||
// Of is the kind of err: "timeout", "canceled", "http_<code>", "sql_<state>", "network",
|
||||
// or "error".
|
||||
// It is empty for nil.
|
||||
func Of(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
var hs HTTPStatuser
|
||||
var ss SQLStater
|
||||
var ne net.Error
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return "timeout"
|
||||
case errors.Is(err, context.Canceled):
|
||||
return "canceled"
|
||||
case errors.As(err, &hs):
|
||||
return fmt.Sprintf("http_%d", hs.HTTPStatus())
|
||||
case errors.As(err, &ss) && ss.SQLState() != "":
|
||||
return "sql_" + ss.SQLState()
|
||||
case errors.As(err, &ne):
|
||||
if ne.Timeout() {
|
||||
return "timeout"
|
||||
}
|
||||
return "network"
|
||||
}
|
||||
return "error"
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package errkind_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"git.d-ma.be/mathias/tapir/internal/errkind"
|
||||
)
|
||||
|
||||
// Of reduces an error to a short kind that is safe to log, store and show: it never
|
||||
// carries the error's own text, because that text can hold a provider's response body, a
|
||||
// file path or a word from a private transcript (ADR-035 review F3, F5).
|
||||
|
||||
type statusErr struct{ code int }
|
||||
|
||||
func (e statusErr) Error() string {
|
||||
return fmt.Sprintf("gateway said: %d and echoed the transcript", e.code)
|
||||
}
|
||||
func (e statusErr) HTTPStatus() int { return e.code }
|
||||
|
||||
type timeoutNet struct{}
|
||||
|
||||
func (timeoutNet) Error() string { return "i/o timeout to 10.0.0.1" }
|
||||
func (timeoutNet) Timeout() bool { return true }
|
||||
func (timeoutNet) Temporary() bool { return true }
|
||||
|
||||
func TestOf_ReducesAnErrorToAKindAndNeverItsText(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{"nothing", nil, ""},
|
||||
{"a deadline", context.DeadlineExceeded, "timeout"},
|
||||
{"a wrapped deadline", fmt.Errorf("transcribe: %w", context.DeadlineExceeded), "timeout"},
|
||||
{"a cancellation", fmt.Errorf("job: %w", context.Canceled), "canceled"},
|
||||
{"an HTTP status", statusErr{code: 502}, "http_502"},
|
||||
{"a wrapped HTTP status", fmt.Errorf("model: %w", statusErr{code: 400}), "http_400"},
|
||||
{"a network timeout", timeoutNet{}, "timeout"},
|
||||
{"a network failure", &net.OpError{Op: "dial", Err: errors.New("connection refused")}, "network"},
|
||||
{"a url error wrapping a refusal", &url.Error{Op: "Post", URL: "http://gateway/secret-path", Err: errors.New("refused")}, "network"},
|
||||
{"anything else", errors.New("moov atom not found in /spool/upload-555"), "error"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, errkind.Of(tt.err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type sqlErr struct{}
|
||||
|
||||
func (sqlErr) Error() string {
|
||||
return `ERROR: invalid input syntax for type json: "Anders ansvarar för virket"`
|
||||
}
|
||||
func (sqlErr) SQLState() string { return "22P02" }
|
||||
|
||||
func TestOf_ADatabaseErrorIsReducedToItsSQLStateNeverItsMessage(t *testing.T) {
|
||||
// A Postgres error can quote the offending value, which for a transcript row is
|
||||
// transcript text. The five-character SQLSTATE is a fixed vocabulary (fourth review).
|
||||
err := fmt.Errorf("store: complete upload: %w", sqlErr{})
|
||||
|
||||
got := errkind.Of(err)
|
||||
|
||||
assert.Equal(t, "sql_22P02", got)
|
||||
assert.NotContains(t, got, "Anders")
|
||||
}
|
||||
|
||||
func TestOf_AKindCarriesNoWordFromTheError(t *testing.T) {
|
||||
err := fmt.Errorf("convert /spool/upload-555: %w", errors.New("Anders ansvarar för virket"))
|
||||
|
||||
got := errkind.Of(err)
|
||||
|
||||
assert.NotContains(t, got, "Anders")
|
||||
assert.NotContains(t, got, "spool")
|
||||
}
|
||||
Reference in New Issue
Block a user