Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
813192e5ba | ||
|
|
2ec1557a8c | ||
|
|
68815755e8 | ||
|
|
ba18668796 | ||
|
|
a65f2f2492 | ||
|
|
a43ee19505 | ||
|
|
4301e691f5 | ||
|
|
61d1ff2255 | ||
|
|
9cc954df40 | ||
|
|
e037c6b683 |
@@ -44,7 +44,7 @@ func (f *fakeUploadsStore) SaveProgress(context.Context, string, string, []domai
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeUploadsStore) Heartbeat(context.Context, string, string) error { return nil }
|
func (f *fakeUploadsStore) Heartbeat(context.Context, string, string, time.Time) error { return nil }
|
||||||
|
|
||||||
func (f *fakeUploadsStore) FailInterruptedUploads(context.Context) (int, error) {
|
func (f *fakeUploadsStore) FailInterruptedUploads(context.Context) (int, error) {
|
||||||
f.sweeps++
|
f.sweeps++
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ it** — endpoints and aliases drift, and this file is a snapshot (2026-06-06),
|
|||||||
not LiteLLM. Use `30401` for off-cluster LiteLLM. `confirm` resolved.
|
not LiteLLM. Use `30401` for off-cluster LiteLLM. `confirm` resolved.
|
||||||
- **Speech-to-text (ADR-033):** the same gateway serves `POST /v1/audio/transcriptions` for
|
- **Speech-to-text (ADR-033):** the same gateway serves `POST /v1/audio/transcriptions` for
|
||||||
`iguana/kb-whisper` (primary, local) and `berget/kb-whisper` (fallback, EU cloud — private audio
|
`iguana/kb-whisper` (primary, local) and `berget/kb-whisper` (fallback, EU cloud — private audio
|
||||||
leaves the homelab if it is used). **Call it via the NodePort / in-cluster address above, NOT
|
leaves the homelab if it is used: when the primary fails **or returns an empty transcript**,
|
||||||
|
e.g. a silent or music-only file; the transcript page then says so, #52). **Call it via the NodePort / in-cluster address above, NOT
|
||||||
`https://llm-api.d-ma.be`:** that public ingress (openresty) returns `413` for bodies over
|
`https://llm-api.d-ma.be`:** that public ingress (openresty) returns `413` for bodies over
|
||||||
roughly 8–10 MB (~5 minutes of 16 kHz WAV), while the NodePort took 174 MB (90 minutes) in one
|
roughly 8–10 MB (~5 minutes of 16 kHz WAV), while the NodePort took 174 MB (90 minutes) in one
|
||||||
request (measured 2026-10-01, `docs/research/audio-length-ceiling-2026-10-01.md`). Send multipart
|
request (measured 2026-10-01, `docs/research/audio-length-ceiling-2026-10-01.md`). Send multipart
|
||||||
|
|||||||
@@ -240,6 +240,11 @@ Feature: Upload an audio recording, get a private Swedish transcript, discuss it
|
|||||||
Then it is transcribed anyway and its audio step says how long the audio really is
|
Then it is transcribed anyway and its audio step says how long the audio really is
|
||||||
And a complete recording whose header over-estimated its length is not refused
|
And a complete recording whose header over-estimated its length is not refused
|
||||||
|
|
||||||
|
Scenario: A transcript made by the cloud fallback says the audio left the homelab
|
||||||
|
When the transcript was produced by an endpoint that is not local
|
||||||
|
Then its page says in plain words that the audio left the homelab and names the provider
|
||||||
|
And a locally transcribed recording carries no such note
|
||||||
|
|
||||||
Scenario: A full queue is a retry, not an error
|
Scenario: A full queue is a retry, not an error
|
||||||
When too many recordings are already waiting
|
When too many recordings are already waiting
|
||||||
Then the upload is answered as busy, its row is failed honestly and its audio is deleted
|
Then the upload is answered as busy, its row is failed honestly and its audio is deleted
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
// Observes how much of a gateway response the client will read. The body is read before
|
||||||
|
// the status is looked at, so a misbehaving gateway or proxy that streams forever was
|
||||||
|
// bounded only by the client's overall timeout (#53).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// endlessGateway answers with the given status and streams filler until the client hangs
|
||||||
|
// up; wrote reports how many bytes it managed to send.
|
||||||
|
func endlessGateway(t *testing.T, status int, wrote *atomic.Int64) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(status)
|
||||||
|
chunk := []byte(strings.Repeat("a", 4096))
|
||||||
|
for {
|
||||||
|
n, err := w.Write(chunk)
|
||||||
|
wrote.Add(int64(n))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if f, ok := w.(http.Flusher); ok {
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_ASuccessBodyLargerThanTheCapIsAnErrorAndTheClientStopsReading(t *testing.T) {
|
||||||
|
var wrote atomic.Int64
|
||||||
|
srv := endlessGateway(t, http.StatusOK, &wrote)
|
||||||
|
c := New(srv.URL, "", "m", 30*time.Second, WithMaxResponseBytes(8192))
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err := c.Complete(context.Background(), "s", "u")
|
||||||
|
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "exceeds") {
|
||||||
|
t.Fatalf("want a response-too-large error, got %v", err)
|
||||||
|
}
|
||||||
|
if time.Since(start) > 5*time.Second {
|
||||||
|
t.Fatalf("the client kept reading: took %s", time.Since(start))
|
||||||
|
}
|
||||||
|
// Hanging up lets the server's writes fail; allow what the transport had already buffered.
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
if got := wrote.Load(); got > 8192+1<<20 {
|
||||||
|
t.Fatalf("server managed to send %d bytes after a cap of 8192", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_AFailingBodyIsReadOnlyUpToTheErrorBound(t *testing.T) {
|
||||||
|
var wrote atomic.Int64
|
||||||
|
srv := endlessGateway(t, http.StatusBadGateway, &wrote)
|
||||||
|
c := New(srv.URL, "", "m", 30*time.Second)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err := c.Complete(context.Background(), "s", "u")
|
||||||
|
|
||||||
|
he, ok := err.(*HTTPError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("want *HTTPError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if he.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("code %d", he.Code)
|
||||||
|
}
|
||||||
|
if len(he.Body) > maxErrorBody+1 {
|
||||||
|
t.Fatalf("kept %d bytes of an error body, bound is %d", len(he.Body), maxErrorBody)
|
||||||
|
}
|
||||||
|
if time.Since(start) > 5*time.Second {
|
||||||
|
t.Fatalf("the client kept reading an endless error body: took %s", time.Since(start))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,21 @@ type Client struct {
|
|||||||
temp float64
|
temp float64
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
usageHook func(model string, prompt, completion int)
|
usageHook func(model string, prompt, completion int)
|
||||||
|
maxBody int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultMaxResponseBytes bounds a success body. A completion is a few KB of text; this
|
||||||
|
// leaves room for a long one and none for a gateway that streams forever (#53).
|
||||||
|
const defaultMaxResponseBytes = 8 << 20
|
||||||
|
|
||||||
|
// WithMaxResponseBytes overrides the bound on a success response body. A non-positive n is
|
||||||
|
// ignored (keeps the default).
|
||||||
|
func WithMaxResponseBytes(n int64) Option {
|
||||||
|
return func(c *Client) {
|
||||||
|
if n > 0 {
|
||||||
|
c.maxBody = n
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Option configures a Client at construction. Variadic so the existing 4-arg
|
// Option configures a Client at construction. Variadic so the existing 4-arg
|
||||||
@@ -101,6 +116,7 @@ func New(baseURL, apiKey, model string, timeout time.Duration, opts ...Option) *
|
|||||||
maxTokens: defaultMaxTokens,
|
maxTokens: defaultMaxTokens,
|
||||||
temp: defaultTemperature,
|
temp: defaultTemperature,
|
||||||
httpClient: &http.Client{Timeout: timeout},
|
httpClient: &http.Client{Timeout: timeout},
|
||||||
|
maxBody: defaultMaxResponseBytes,
|
||||||
}
|
}
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(c)
|
opt(c)
|
||||||
@@ -184,12 +200,17 @@ func (c *Client) Complete(ctx context.Context, system, user string) (string, err
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close() //nolint:errcheck
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
|
||||||
out, err := io.ReadAll(resp.Body)
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
// Only the bounded head is ever used (HTTPError.Error truncates to it), so read no more.
|
||||||
|
head, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody+1))
|
||||||
|
return "", &HTTPError{Code: resp.StatusCode, Body: string(head)}
|
||||||
|
}
|
||||||
|
out, err := io.ReadAll(io.LimitReader(resp.Body, c.maxBody+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("read response: %w", err)
|
return "", fmt.Errorf("read response: %w", err)
|
||||||
}
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if int64(len(out)) > c.maxBody {
|
||||||
return "", &HTTPError{Code: resp.StatusCode, Body: string(out)}
|
return "", fmt.Errorf("LLM response exceeds %d bytes", c.maxBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
var cr chatResponse
|
var cr chatResponse
|
||||||
|
|||||||
@@ -68,13 +68,13 @@ func TestUploadProgress_AHeartbeatMovesAliveAtAndNotProgressAt(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
at := time.Date(2026, 10, 2, 8, 30, 0, 0, time.UTC)
|
at := time.Date(2026, 10, 2, 8, 30, 0, 0, time.UTC)
|
||||||
require.NoError(t, s.SaveProgress(ctx, userA, id, []domain.ProgressStep{{Key: "audio", Label: "a", State: domain.StepRunning}}, at))
|
require.NoError(t, s.SaveProgress(ctx, userA, id, []domain.ProgressStep{{Key: "audio", Label: "a", State: domain.StepRunning}}, at))
|
||||||
before := time.Now().Add(-time.Second)
|
beatAt := at.Add(90 * time.Second) // deliberately not the database's NOW()
|
||||||
|
|
||||||
require.NoError(t, s.Heartbeat(ctx, userA, id))
|
require.NoError(t, s.Heartbeat(ctx, userA, id, beatAt))
|
||||||
|
|
||||||
got, err := s.GetUpload(ctx, userA, id)
|
got, err := s.GetUpload(ctx, userA, id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, got.AliveAt.After(before), "the heartbeat must stamp the row with now")
|
assert.True(t, got.AliveAt.Equal(beatAt), "the heartbeat is stamped with the application's clock, the same one progress_at uses, not the database's NOW() (#51)")
|
||||||
assert.True(t, got.ProgressAt.Equal(at), "alive is not progress: a heartbeat must not move progress_at")
|
assert.True(t, got.ProgressAt.Equal(at), "alive is not progress: a heartbeat must not move progress_at")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ func TestUploadProgress_ByAnotherUserChangesNothing(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
errSave := s.SaveProgress(ctx, userB, id, []domain.ProgressStep{{Key: "x", Label: "X", State: domain.StepDone}}, time.Now())
|
errSave := s.SaveProgress(ctx, userB, id, []domain.ProgressStep{{Key: "x", Label: "X", State: domain.StepDone}}, time.Now())
|
||||||
errBeat := s.Heartbeat(ctx, userB, id)
|
errBeat := s.Heartbeat(ctx, userB, id, time.Now())
|
||||||
|
|
||||||
assert.ErrorIs(t, errSave, store.ErrUploadNotFound)
|
assert.ErrorIs(t, errSave, store.ErrUploadNotFound)
|
||||||
assert.ErrorIs(t, errBeat, store.ErrUploadNotFound)
|
assert.ErrorIs(t, errBeat, store.ErrUploadNotFound)
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ type Sink interface {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
defaultMinSave = 2 * time.Second
|
defaultMinSave = 2 * time.Second
|
||||||
|
defaultSaveTimeout = 5 * time.Second
|
||||||
logAdvanceEach = time.Minute
|
logAdvanceEach = time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,6 +56,11 @@ func WithClock(now func() time.Time) Option { return func(t *Tracker) { t.now =
|
|||||||
// Done, Fail and Finish always save.
|
// Done, Fail and Finish always save.
|
||||||
func WithMinSave(d time.Duration) Option { return func(t *Tracker) { t.minSave = d } }
|
func WithMinSave(d time.Duration) Option { return func(t *Tracker) { t.minSave = d } }
|
||||||
|
|
||||||
|
// WithSaveTimeout bounds one save or heartbeat write. Progress is cosmetic, so a database
|
||||||
|
// that stops answering costs a dropped write, never a stalled job: saves run under the
|
||||||
|
// tracker's lock, which Begin and Advance (called by the engines) need (#51).
|
||||||
|
func WithSaveTimeout(d time.Duration) Option { return func(t *Tracker) { t.saveTimeout = d } }
|
||||||
|
|
||||||
// Tracker implements Reporter for one job.
|
// Tracker implements Reporter for one job.
|
||||||
type Tracker struct {
|
type Tracker struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
@@ -63,6 +69,8 @@ type Tracker struct {
|
|||||||
now func() time.Time
|
now func() time.Time
|
||||||
minSave time.Duration
|
minSave time.Duration
|
||||||
|
|
||||||
|
saveTimeout time.Duration
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
steps []domain.ProgressStep
|
steps []domain.ProgressStep
|
||||||
lastAdvanceSave time.Time
|
lastAdvanceSave time.Time
|
||||||
@@ -72,7 +80,7 @@ type Tracker struct {
|
|||||||
// NewTracker plans a job's phases. ctx is used for the saves, so it should outlive the
|
// NewTracker plans a job's phases. ctx is used for the saves, so it should outlive the
|
||||||
// job's own timeout: a job that timed out still gets its failure recorded.
|
// job's own timeout: a job that timed out still gets its failure recorded.
|
||||||
func NewTracker(ctx context.Context, sink Sink, log *slog.Logger, plan []Planned, opts ...Option) *Tracker {
|
func NewTracker(ctx context.Context, sink Sink, log *slog.Logger, plan []Planned, opts ...Option) *Tracker {
|
||||||
t := &Tracker{ctx: ctx, sink: sink, log: log, now: time.Now, minSave: defaultMinSave}
|
t := &Tracker{ctx: ctx, sink: sink, log: log, now: time.Now, minSave: defaultMinSave, saveTimeout: defaultSaveTimeout}
|
||||||
for _, o := range opts {
|
for _, o := range opts {
|
||||||
o(t)
|
o(t)
|
||||||
}
|
}
|
||||||
@@ -212,7 +220,10 @@ func (t *Tracker) Heartbeat(ctx context.Context, beatEvery, stillEvery time.Dura
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-beat.C:
|
case <-beat.C:
|
||||||
if err := t.sink.Beat(ctx); err != nil && ctx.Err() == nil {
|
beatCtx, cancel := context.WithTimeout(ctx, t.saveTimeout)
|
||||||
|
err := t.sink.Beat(beatCtx)
|
||||||
|
cancel()
|
||||||
|
if err != nil && ctx.Err() == nil {
|
||||||
t.log.Warn("progress: heartbeat not saved", "err", err)
|
t.log.Warn("progress: heartbeat not saved", "err", err)
|
||||||
}
|
}
|
||||||
case <-still.C:
|
case <-still.C:
|
||||||
@@ -254,7 +265,9 @@ func (t *Tracker) find(key string) *domain.ProgressStep {
|
|||||||
// dropped: progress is cosmetic and must never fail the job it describes.
|
// dropped: progress is cosmetic and must never fail the job it describes.
|
||||||
func (t *Tracker) save() {
|
func (t *Tracker) save() {
|
||||||
snapshot := append([]domain.ProgressStep(nil), t.steps...)
|
snapshot := append([]domain.ProgressStep(nil), t.steps...)
|
||||||
if err := t.sink.SaveProgress(t.ctx, snapshot, t.now()); err != nil && t.ctx.Err() == nil {
|
ctx, cancel := context.WithTimeout(t.ctx, t.saveTimeout)
|
||||||
|
defer cancel()
|
||||||
|
if err := t.sink.SaveProgress(ctx, snapshot, t.now()); err != nil && t.ctx.Err() == nil {
|
||||||
t.log.Warn("progress: not saved", "err", err)
|
t.log.Warn("progress: not saved", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package progress_test
|
||||||
|
|
||||||
|
// Observes what a database that stops answering does to the job it reports on (#51):
|
||||||
|
// progress is cosmetic, so a save that never returns must not stall the engine calls that
|
||||||
|
// report through the tracker, nor the heartbeat that says the process is alive.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
|
"git.d-ma.be/mathias/tapir/internal/progress"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stallSink never answers until its context ends, like a database connection that hung.
|
||||||
|
type stallSink struct{ beats atomic.Int32 }
|
||||||
|
|
||||||
|
func (s *stallSink) SaveProgress(ctx context.Context, _ []domain.ProgressStep, _ time.Time) error {
|
||||||
|
<-ctx.Done()
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stallSink) Beat(ctx context.Context, _ time.Time) error {
|
||||||
|
s.beats.Add(1)
|
||||||
|
<-ctx.Done()
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type lockedBuf struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
b bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *lockedBuf) Write(p []byte) (int, error) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
return l.b.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *lockedBuf) String() string {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
return l.b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stallTracker(sink progress.Sink, buf *lockedBuf) *progress.Tracker {
|
||||||
|
log := slog.New(slog.NewTextHandler(buf, nil))
|
||||||
|
return progress.NewTracker(context.Background(), sink, log,
|
||||||
|
[]progress.Planned{{Key: "a", Label: "A"}}, progress.WithSaveTimeout(50*time.Millisecond))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTracker_AHungSaveDoesNotBlockTheReporterPastItsTimeout(t *testing.T) {
|
||||||
|
buf := &lockedBuf{}
|
||||||
|
tr := stallTracker(&stallSink{}, buf)
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { tr.Begin("a", 0); tr.Advance("a", 1); tr.Done("a"); close(done) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("the reporter was blocked by a save that never returned")
|
||||||
|
}
|
||||||
|
require.Contains(t, buf.String(), "progress: not saved", "a dropped save is logged")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTracker_AHungBeatDoesNotStopTheHeartbeat(t *testing.T) {
|
||||||
|
sink := &stallSink{}
|
||||||
|
tr := stallTracker(sink, &lockedBuf{})
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
go tr.Heartbeat(ctx, 5*time.Millisecond, time.Hour)
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool { return sink.beats.Load() >= 3 }, 3*time.Second, 5*time.Millisecond,
|
||||||
|
"each beat is abandoned at its timeout and the next one is tried")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTracker_TheStillRunningLineIsNotHeldUpBehindAHungSave(t *testing.T) {
|
||||||
|
buf := &lockedBuf{}
|
||||||
|
tr := stallTracker(&stallSink{}, buf)
|
||||||
|
go tr.Begin("a", 0) // holds the tracker's lock inside a save that hangs until its timeout
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
go tr.Heartbeat(ctx, time.Hour, 5*time.Millisecond)
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool { return strings.Contains(buf.String(), "still running") }, 3*time.Second, 5*time.Millisecond)
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ type fakeSink struct {
|
|||||||
saves [][]domain.ProgressStep
|
saves [][]domain.ProgressStep
|
||||||
at []time.Time
|
at []time.Time
|
||||||
beats int
|
beats int
|
||||||
|
beatAt []time.Time
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,10 +33,11 @@ func (f *fakeSink) SaveProgress(_ context.Context, steps []domain.ProgressStep,
|
|||||||
return f.err
|
return f.err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeSink) Beat(context.Context) error {
|
func (f *fakeSink) Beat(_ context.Context, at time.Time) error {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
f.beats++
|
f.beats++
|
||||||
|
f.beatAt = append(f.beatAt, at)
|
||||||
return f.err
|
return f.err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,7 +299,7 @@ func (r *retainingSink) SaveProgress(_ context.Context, steps []domain.ProgressS
|
|||||||
r.got = append(r.got, steps)
|
r.got = append(r.got, steps)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (r *retainingSink) Beat(context.Context) error { return nil }
|
func (r *retainingSink) Beat(context.Context, time.Time) error { return nil }
|
||||||
|
|
||||||
func TestTracker_ASnapshotHandedToTheSinkIsNotChangedByLaterTransitions(t *testing.T) {
|
func TestTracker_ASnapshotHandedToTheSinkIsNotChangedByLaterTransitions(t *testing.T) {
|
||||||
sink := &retainingSink{}
|
sink := &retainingSink{}
|
||||||
@@ -400,3 +402,20 @@ func TestTracker_AnnotateAttachesANoteToAStepAndSavesItAtOnce(t *testing.T) {
|
|||||||
require.Equal(t, "20 min of the 79 the file claimed", sink.last(t)[0].Note)
|
require.Equal(t, "20 min of the 79 the file claimed", sink.last(t)[0].Note)
|
||||||
require.NotPanics(t, func() { tr.Annotate("no-such-step", "x") })
|
require.NotPanics(t, func() { tr.Annotate("no-such-step", "x") })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTracker_BeatIsStampedWithTheTrackersClock(t *testing.T) {
|
||||||
|
// alive_at used the database's NOW() while the page compared it with the application
|
||||||
|
// clock; a skewed database clock showed a healthy job as lost (#51).
|
||||||
|
tr, sink, clk, _ := newTracker(t)
|
||||||
|
clk.tick(42 * time.Second)
|
||||||
|
want := clk.now()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
go tr.Heartbeat(ctx, 5*time.Millisecond, time.Hour)
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool { return sink.beatCount() >= 1 }, 2*time.Second, 5*time.Millisecond)
|
||||||
|
sink.mu.Lock()
|
||||||
|
defer sink.mu.Unlock()
|
||||||
|
require.True(t, sink.beatAt[0].Equal(want), "beat stamped %v, the tracker's clock says %v", sink.beatAt[0], want)
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ func (s *wStore) SaveProgress(_ context.Context, _, _ string, steps []domain.Pro
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *wStore) Heartbeat(context.Context, string, string) error {
|
func (s *wStore) Heartbeat(context.Context, string, string, time.Time) error {
|
||||||
time.Sleep(s.beatDelay)
|
time.Sleep(s.beatDelay)
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
|
|
||||||
"git.d-ma.be/mathias/tapir/internal/adapters/chat"
|
"git.d-ma.be/mathias/tapir/internal/adapters/chat"
|
||||||
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
|
"git.d-ma.be/mathias/tapir/internal/config"
|
||||||
"git.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
"git.d-ma.be/mathias/tapir/internal/errkind"
|
"git.d-ma.be/mathias/tapir/internal/errkind"
|
||||||
"git.d-ma.be/mathias/tapir/internal/uploads"
|
"git.d-ma.be/mathias/tapir/internal/uploads"
|
||||||
@@ -243,6 +244,17 @@ func titleFromFilename(name string) string {
|
|||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cloudProvider names the cloud service that produced a transcript, or false when it was
|
||||||
|
// served locally (or by an unknown endpoint). "Local" is config.IsLocalModel: the same
|
||||||
|
// definition the start-up validation uses, not a second string match here (#52).
|
||||||
|
func cloudProvider(endpoint string) (string, bool) {
|
||||||
|
if endpoint == "" || config.IsLocalModel(endpoint) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
provider, _, _ := strings.Cut(endpoint, "/")
|
||||||
|
return provider, true
|
||||||
|
}
|
||||||
|
|
||||||
// hasNote reports whether any phase carries a note. A note is the only warning for a
|
// 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.
|
// recording that is shorter than its file claimed, so a record that has one is shown open.
|
||||||
func hasNote(steps []domain.ProgressStep) bool {
|
func hasNote(steps []domain.ProgressStep) bool {
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
|
|||||||
Transcribed { uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))) }
|
Transcribed { uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))) }
|
||||||
of audio by <span class="chip">{ u.Endpoint }</span>.
|
of audio by <span class="chip">{ u.Endpoint }</span>.
|
||||||
</p>
|
</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 })
|
||||||
|
because the local engine could not.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
@reconciliationNote(u.Reconciliation)
|
@reconciliationNote(u.Reconciliation)
|
||||||
if len(u.Progress) > 0 {
|
if len(u.Progress) > 0 {
|
||||||
<details class="what-was-done" if hasNote(u.Progress) { open }>
|
<details class="what-was-done" if hasNote(u.Progress) { open }>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
// A transcript produced by the cloud fallback means this recording's audio left the
|
||||||
|
// homelab, which the reader is told in plain words (#52). "Local" is config.IsLocalModel:
|
||||||
|
// one definition, not a second string match in the view.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setEndpoint(t *testing.T, id, endpoint string) {
|
||||||
|
t.Helper()
|
||||||
|
_, err := rawPool(t).Exec(context.Background(), `UPDATE uploaded_transcripts SET endpoint = $2 WHERE id = $1`, id, endpoint)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadStatus_ACloudServedTranscriptSaysTheAudioLeftTheHomelab(t *testing.T) {
|
||||||
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||||
|
id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
|
||||||
|
setEndpoint(t, id, "berget/kb-whisper")
|
||||||
|
|
||||||
|
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
|
||||||
|
|
||||||
|
assert.Contains(t, b, "left the homelab")
|
||||||
|
assert.Contains(t, b, "cloud service (berget)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadStatus_ALocallyServedTranscriptCarriesNoCloudNote(t *testing.T) {
|
||||||
|
for _, endpoint := range []string{"iguana/kb-whisper", "koala/whisper", ""} {
|
||||||
|
t.Run(endpoint, func(t *testing.T) {
|
||||||
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||||
|
id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
|
||||||
|
setEndpoint(t, id, endpoint)
|
||||||
|
|
||||||
|
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
|
||||||
|
|
||||||
|
assert.NotContains(t, b, "left the homelab")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadStatus_AnEndpointThatOnlyLooksLocalIsStillNamedAsCloud(t *testing.T) {
|
||||||
|
// The prefix alone must not decide it: IsLocalModel refuses path tricks.
|
||||||
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||||
|
id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
|
||||||
|
setEndpoint(t, id, "iguana/../berget/kb-whisper")
|
||||||
|
|
||||||
|
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
|
||||||
|
|
||||||
|
assert.Contains(t, b, "left the homelab")
|
||||||
|
}
|
||||||
+303
-284
File diff suppressed because it is too large
Load Diff
@@ -122,6 +122,8 @@ var scenarioCoverage = map[string]string{
|
|||||||
"I can ask about my recording and get a grounded answer": "TestUploadChatPost_SendsTheOwnersTranscriptWithTheUploadBudget",
|
"I can ask about my recording and get a grounded answer": "TestUploadChatPost_SendsTheOwnersTranscriptWithTheUploadBudget",
|
||||||
"A long recording is not cut to its first third": "TestAnswerMaxCharsOverridesTheServiceCap",
|
"A long recording is not cut to its first third": "TestAnswerMaxCharsOverridesTheServiceCap",
|
||||||
|
|
||||||
|
"A transcript made by the cloud fallback says the audio left the homelab": "TestUploadStatus_ACloudServedTranscriptSaysTheAudioLeftTheHomelab",
|
||||||
|
|
||||||
// audio_upload.feature, ADR-034
|
// audio_upload.feature, ADR-034
|
||||||
"Two engines transcribe the audio and their disagreements are reconciled": "TestEnsemble_BothEnginesRunInOrderAndTheirDisagreementIsReconciled",
|
"Two engines transcribe the audio and their disagreements are reconciled": "TestEnsemble_BothEnginesRunInOrderAndTheirDisagreementIsReconciled",
|
||||||
"Trivial disagreements never reach the model": "TestReconcile_TrivialDisputesAreResolvedWithoutAskingTheModel",
|
"Trivial disagreements never reach the model": "TestReconcile_TrivialDisputesAreResolvedWithoutAskingTheModel",
|
||||||
|
|||||||
Reference in New Issue
Block a user