Compare commits
8
Commits
f25acebe4c
...
6fb7a52980
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fb7a52980 | ||
|
|
d6ba11d288 | ||
|
|
ae4f9fd775 | ||
|
|
e838f6bb6d | ||
|
|
5bd41077af | ||
|
|
34bc8dbdee | ||
|
|
8778376aa6 | ||
|
|
ad39b6154c |
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE uploaded_transcripts DROP COLUMN reconciliation;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Migration 019: how a merged transcript came about (ADR-034).
|
||||||
|
--
|
||||||
|
-- When two engines transcribed the same audio and a local model chose between their
|
||||||
|
-- disagreements, this records the status, the exact counts, and a bounded list of the
|
||||||
|
-- contested spots (changed or unsure, each with a position and both readings) so the
|
||||||
|
-- page can say what was done and the user can check the uncertain spots against the
|
||||||
|
-- audio. NULL means a single engine: no second opinion, and the page must not claim one.
|
||||||
|
--
|
||||||
|
-- It lives on the already-FORCE'd, RLS-scoped uploaded_transcripts, so the exact-set
|
||||||
|
-- isolation test is unaffected. The contested spots hold fragments of the private
|
||||||
|
-- transcript, which is why they are here and nowhere else.
|
||||||
|
ALTER TABLE uploaded_transcripts ADD COLUMN reconciliation JSONB;
|
||||||
@@ -52,12 +52,20 @@ func (s *Store) CompleteUpload(ctx context.Context, userID, id string, t domain.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("store: complete upload: marshal segments: %w", err)
|
return fmt.Errorf("store: complete upload: marshal segments: %w", err)
|
||||||
}
|
}
|
||||||
|
// A nil Reconciliation must be SQL NULL, not the JSON text "null": the page decides
|
||||||
|
// whether to claim a second opinion by whether this column is NULL.
|
||||||
|
var rec []byte
|
||||||
|
if t.Reconciliation != nil {
|
||||||
|
if rec, err = json.Marshal(t.Reconciliation); err != nil {
|
||||||
|
return fmt.Errorf("store: complete upload: marshal reconciliation: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return s.updateUpload(ctx, userID, id, "complete upload",
|
return s.updateUpload(ctx, userID, id, "complete upload",
|
||||||
`UPDATE uploaded_transcripts
|
`UPDATE uploaded_transcripts
|
||||||
SET status = 'done', content = $3, segments = $4, endpoint = $5,
|
SET status = 'done', content = $3, segments = $4, endpoint = $5,
|
||||||
audio_seconds = $6, error_kind = NULL, finished_at = NOW()
|
audio_seconds = $6, reconciliation = $7, error_kind = NULL, finished_at = NOW()
|
||||||
WHERE id = $1 AND user_id = $2`,
|
WHERE id = $1 AND user_id = $2`,
|
||||||
t.Text, segs, t.Endpoint, t.AudioSeconds)
|
t.Text, segs, t.Endpoint, t.AudioSeconds, rec)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FailUpload moves an upload to failed with a typed reason.
|
// FailUpload moves an upload to failed with a typed reason.
|
||||||
@@ -157,19 +165,19 @@ func (s *Store) FailInterruptedUploads(ctx context.Context) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uploadSelect = `SELECT id, user_id, title, status, content, segments, endpoint,
|
const uploadSelect = `SELECT id, user_id, title, status, content, segments, endpoint,
|
||||||
audio_seconds, error_kind, had_video, created_at, finished_at FROM uploaded_transcripts`
|
audio_seconds, error_kind, had_video, reconciliation, created_at, finished_at FROM uploaded_transcripts`
|
||||||
|
|
||||||
// scanUpload scans one uploadSelect row. withText controls whether the transcript
|
// scanUpload scans one uploadSelect row. withText controls whether the transcript
|
||||||
// body and segments are kept (GetUpload) or dropped (ListUploads).
|
// body and segments are kept (GetUpload) or dropped (ListUploads).
|
||||||
func scanUpload(row pgx.Row, u *domain.UploadedTranscript, withText bool) error {
|
func scanUpload(row pgx.Row, u *domain.UploadedTranscript, withText bool) error {
|
||||||
var (
|
var (
|
||||||
status, errKind, endpoint, content *string
|
status, errKind, endpoint, content *string
|
||||||
segments []byte
|
segments, reconciliation []byte
|
||||||
seconds *float64
|
seconds *float64
|
||||||
finished *time.Time
|
finished *time.Time
|
||||||
)
|
)
|
||||||
if err := row.Scan(&u.ID, &u.UserID, &u.Title, &status, &content, &segments, &endpoint,
|
if err := row.Scan(&u.ID, &u.UserID, &u.Title, &status, &content, &segments, &endpoint,
|
||||||
&seconds, &errKind, &u.HadVideo, &u.CreatedAt, &finished); err != nil {
|
&seconds, &errKind, &u.HadVideo, &reconciliation, &u.CreatedAt, &finished); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u.Status = domain.UploadStatus(deref(status))
|
u.Status = domain.UploadStatus(deref(status))
|
||||||
@@ -184,6 +192,13 @@ func scanUpload(row pgx.Row, u *domain.UploadedTranscript, withText bool) error
|
|||||||
if !withText {
|
if !withText {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if len(reconciliation) > 0 {
|
||||||
|
var r domain.Reconciliation
|
||||||
|
if err := json.Unmarshal(reconciliation, &r); err != nil {
|
||||||
|
return fmt.Errorf("unmarshal reconciliation: %w", err)
|
||||||
|
}
|
||||||
|
u.Reconciliation = &r
|
||||||
|
}
|
||||||
u.Content = deref(content)
|
u.Content = deref(content)
|
||||||
if len(segments) > 0 {
|
if len(segments) > 0 {
|
||||||
if err := json.Unmarshal(segments, &u.Segments); err != nil {
|
if err := json.Unmarshal(segments, &u.Segments); err != nil {
|
||||||
|
|||||||
@@ -230,3 +230,57 @@ func TestUploadHadVideo_SurvivesCompletionAndFailure(t *testing.T) {
|
|||||||
assert.True(t, got.HadVideo, "completing or failing must not clear it: the notice outlives the job")
|
assert.True(t, got.HadVideo, "completing or failing must not clear it: the notice outlives the job")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUploadLifecycle_ReconciliationRoundTripsWithItsContestedSpots(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
seedUsers(t, userA)
|
||||||
|
ctx := context.Background()
|
||||||
|
id, err := s.CreateUpload(ctx, userA, "m", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, s.StartTranscribing(ctx, userA, id))
|
||||||
|
want := &domain.Reconciliation{
|
||||||
|
Status: domain.ReconcileDone, Base: "iguana/pianissimo", Alt: "iguana/kb-whisper", Reconciler: "iguana/gemma4-26b",
|
||||||
|
Disputes: 380, AutoResolved: 90, Adjudicated: 285, Changed: 120, Unsure: 5, Errors: 0,
|
||||||
|
Contested: []domain.ContestedSpot{
|
||||||
|
{Seconds: 2210.5, Base: "SEB", Alt: "SCB", Used: "alt"},
|
||||||
|
{Seconds: 3400, Base: "T+1", Alt: "en T", Used: "base", Unsure: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, s.CompleteUpload(ctx, userA, id, domain.Transcription{Text: "x", Endpoint: "e", Reconciliation: want}))
|
||||||
|
|
||||||
|
got, err := s.GetUpload(ctx, userA, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, want, got.Reconciliation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadLifecycle_ASingleEngineTranscriptHasNoReconciliation(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
seedUsers(t, userA)
|
||||||
|
ctx := context.Background()
|
||||||
|
id, err := s.CreateUpload(ctx, userA, "m", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, s.StartTranscribing(ctx, userA, id))
|
||||||
|
require.NoError(t, s.CompleteUpload(ctx, userA, id, domain.Transcription{Text: "x", Endpoint: "e"}))
|
||||||
|
|
||||||
|
got, err := s.GetUpload(ctx, userA, id)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, got.Reconciliation, "no reconciliation is not an empty one: the page must not claim a second opinion")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadLifecycle_ASkippedReconciliationKeepsItsReason(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
seedUsers(t, userA)
|
||||||
|
ctx := context.Background()
|
||||||
|
id, err := s.CreateUpload(ctx, userA, "m", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, s.StartTranscribing(ctx, userA, id))
|
||||||
|
rec := &domain.Reconciliation{Status: domain.ReconcileSkipped, Reason: "the model failed 3 times in a row: gateway down", Errors: 3}
|
||||||
|
|
||||||
|
require.NoError(t, s.CompleteUpload(ctx, userA, id, domain.Transcription{Text: "x", Endpoint: "e", Reconciliation: rec}))
|
||||||
|
|
||||||
|
got, err := s.GetUpload(ctx, userA, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, rec, got.Reconciliation)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package transcribe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
|
"git.d-ma.be/mathias/tapir/internal/reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensemble transcribes the same WAV with two engines, one after the other, and has a
|
||||||
|
// local model reconcile their disagreements (ADR-034). It satisfies uploads.Transcriber,
|
||||||
|
// so the worker neither knows nor cares that two engines were involved.
|
||||||
|
//
|
||||||
|
// It degrades in steps and never turns a recoverable problem into a failed job:
|
||||||
|
// one engine down → the other's text, said so on the result; the model down → the
|
||||||
|
// base text unreconciled; both engines down → the cloud fallback, if configured, and
|
||||||
|
// recorded as such. A cancelled job stops everything and never reaches the cloud.
|
||||||
|
type Ensemble struct {
|
||||||
|
base, alt Backend
|
||||||
|
fallback Backend // nil = no cloud fallback
|
||||||
|
adj reconcile.Adjudicator
|
||||||
|
opts reconcile.Options
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEnsemble builds an Ensemble. base is the engine whose text stands where the two
|
||||||
|
// disagree and is the faster one, so it runs first; alt supplies the other reading.
|
||||||
|
func NewEnsemble(base, alt, fallback Backend, adj reconcile.Adjudicator, opts reconcile.Options) *Ensemble {
|
||||||
|
return &Ensemble{base: base, alt: alt, fallback: fallback, adj: adj, opts: opts}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transcribe implements uploads.Transcriber.
|
||||||
|
func (e *Ensemble) Transcribe(ctx context.Context, wavPath string) (domain.Transcription, error) {
|
||||||
|
b, berr := e.base.Transcribe(ctx, wavPath)
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return domain.Transcription{}, err
|
||||||
|
}
|
||||||
|
a, aerr := e.alt.Transcribe(ctx, wavPath)
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return domain.Transcription{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case berr == nil && aerr == nil:
|
||||||
|
out, err := reconcile.Reconcile(ctx, b, a, e.adj, e.opts)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Transcription{}, err
|
||||||
|
}
|
||||||
|
out.Endpoint = fmt.Sprintf("%s + %s, reconciled by %s", e.base.Name(), e.alt.Name(), e.opts.Reconciler)
|
||||||
|
return out, nil
|
||||||
|
case berr == nil:
|
||||||
|
return e.single(b, e.base.Name(), fmt.Sprintf("%s failed: %v", e.alt.Name(), aerr)), nil
|
||||||
|
case aerr == nil:
|
||||||
|
return e.single(a, e.alt.Name(), fmt.Sprintf("%s failed: %v", e.base.Name(), berr)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
both := errors.Join(fmt.Errorf("%s: %w", e.base.Name(), berr), fmt.Errorf("%s: %w", e.alt.Name(), aerr))
|
||||||
|
if e.fallback == nil {
|
||||||
|
return domain.Transcription{}, both
|
||||||
|
}
|
||||||
|
f, ferr := e.fallback.Transcribe(ctx, wavPath)
|
||||||
|
if ferr != nil {
|
||||||
|
return domain.Transcription{}, errors.Join(both, fmt.Errorf("%s: %w", e.fallback.Name(), ferr))
|
||||||
|
}
|
||||||
|
return e.single(f, e.fallback.Name(), "both local engines failed"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// single is a transcript from one engine only: nothing to reconcile, and the result
|
||||||
|
// says why, because a reader should know the second opinion was missing.
|
||||||
|
func (e *Ensemble) single(t domain.Transcription, engine, reason string) domain.Transcription {
|
||||||
|
t.Endpoint = engine
|
||||||
|
t.Reconciliation = &domain.Reconciliation{
|
||||||
|
Status: domain.ReconcileSingle, Reason: reason, Base: e.opts.BaseEngine, Alt: e.opts.AltEngine, Reconciler: e.opts.Reconciler,
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package transcribe_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/tapir/internal/adapters/transcribe"
|
||||||
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
|
"git.d-ma.be/mathias/tapir/internal/reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// orderedBackend records, in a shared slice, when it was called.
|
||||||
|
type orderedBackend struct {
|
||||||
|
name string
|
||||||
|
text string
|
||||||
|
err error
|
||||||
|
calls *[]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *orderedBackend) Name() string { return o.name }
|
||||||
|
func (o *orderedBackend) Transcribe(_ context.Context, _ string) (domain.Transcription, error) {
|
||||||
|
*o.calls = append(*o.calls, o.name)
|
||||||
|
if o.err != nil {
|
||||||
|
return domain.Transcription{}, o.err
|
||||||
|
}
|
||||||
|
return domain.Transcription{Text: o.text, Endpoint: o.name, AudioSeconds: 10}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type verdictAdj struct {
|
||||||
|
v reconcile.Verdict
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *verdictAdj) Adjudicate(context.Context, reconcile.Request) (reconcile.Verdict, error) {
|
||||||
|
a.calls++
|
||||||
|
return a.v, a.err
|
||||||
|
}
|
||||||
|
|
||||||
|
var ensembleOpts = reconcile.Options{Reconciler: "iguana/gemma4-26b", BaseEngine: "iguana/pianissimo", AltEngine: "iguana/kb-whisper"}
|
||||||
|
|
||||||
|
type rig struct {
|
||||||
|
e *transcribe.Ensemble
|
||||||
|
calls *[]string
|
||||||
|
adj *verdictAdj
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEnsemble(baseText, altText string, baseErr, altErr error, withFallback bool, adj *verdictAdj) *rig {
|
||||||
|
calls := &[]string{}
|
||||||
|
base := &orderedBackend{name: "iguana/pianissimo", text: baseText, err: baseErr, calls: calls}
|
||||||
|
alt := &orderedBackend{name: "iguana/kb-whisper", text: altText, err: altErr, calls: calls}
|
||||||
|
var fb transcribe.Backend
|
||||||
|
if withFallback {
|
||||||
|
fb = &orderedBackend{name: "berget/kb-whisper", text: "fallback text", calls: calls}
|
||||||
|
}
|
||||||
|
return &rig{e: transcribe.NewEnsemble(base, alt, fb, adj, ensembleOpts), calls: calls, adj: adj}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsemble_BothEnginesRunInOrderAndTheirDisagreementIsReconciled(t *testing.T) {
|
||||||
|
adj := &verdictAdj{v: reconcile.VerdictAlt}
|
||||||
|
r := newEnsemble("Totalt 1015 kronor idag.", "Totalt 1050 kronor idag.", nil, nil, true, adj)
|
||||||
|
|
||||||
|
got, err := r.e.Transcribe(context.Background(), "/a.wav")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"iguana/pianissimo", "iguana/kb-whisper"}, *r.calls,
|
||||||
|
"the 40-second engine runs first, so a failure there is known early; the fallback is not touched")
|
||||||
|
assert.Equal(t, "Totalt 1050 kronor idag.", got.Text)
|
||||||
|
assert.Equal(t, "iguana/pianissimo + iguana/kb-whisper, reconciled by iguana/gemma4-26b", got.Endpoint,
|
||||||
|
"every contributor is recorded: which engines touched the audio is a quality and a privacy fact")
|
||||||
|
require.NotNil(t, got.Reconciliation)
|
||||||
|
assert.Equal(t, domain.ReconcileDone, got.Reconciliation.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsemble_IdenticalTextsNeverCallTheModel(t *testing.T) {
|
||||||
|
adj := &verdictAdj{v: reconcile.VerdictAlt}
|
||||||
|
r := newEnsemble("Vi går igenom läget.", "Vi går igenom läget.", nil, nil, false, adj)
|
||||||
|
|
||||||
|
got, err := r.e.Transcribe(context.Background(), "/a.wav")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Zero(t, adj.calls)
|
||||||
|
assert.Equal(t, "Vi går igenom läget.", got.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsemble_WhenOneEngineFailsTheOtherIsUsedAndItIsSaidSo(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
baseErr error
|
||||||
|
altErr error
|
||||||
|
wantText string
|
||||||
|
wantEndpoint string
|
||||||
|
wantReasonHas string
|
||||||
|
}{
|
||||||
|
{"pianissimo down", errors.New("pianissimo wedged"), nil, "alt text", "iguana/kb-whisper", "iguana/pianissimo failed"},
|
||||||
|
{"kb-whisper down", nil, errors.New("metal oom"), "base text", "iguana/pianissimo", "iguana/kb-whisper failed"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
adj := &verdictAdj{v: reconcile.VerdictAlt}
|
||||||
|
r := newEnsemble("base text", "alt text", tt.baseErr, tt.altErr, true, adj)
|
||||||
|
|
||||||
|
got, err := r.e.Transcribe(context.Background(), "/a.wav")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.wantText, got.Text)
|
||||||
|
assert.Equal(t, tt.wantEndpoint, got.Endpoint)
|
||||||
|
assert.Zero(t, adj.calls, "there is nothing to reconcile")
|
||||||
|
require.NotNil(t, got.Reconciliation)
|
||||||
|
assert.Equal(t, domain.ReconcileSingle, got.Reconciliation.Status)
|
||||||
|
assert.Contains(t, got.Reconciliation.Reason, tt.wantReasonHas)
|
||||||
|
assert.NotContains(t, *r.calls, "berget/kb-whisper", "one local engine working means audio does not leave the homelab")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsemble_OnlyWhenBothLocalEnginesFailDoesTheCloudFallbackRun(t *testing.T) {
|
||||||
|
r := newEnsemble("", "", errors.New("a"), errors.New("b"), true, &verdictAdj{})
|
||||||
|
|
||||||
|
got, err := r.e.Transcribe(context.Background(), "/a.wav")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "fallback text", got.Text)
|
||||||
|
assert.Equal(t, "berget/kb-whisper", got.Endpoint, "the row must say berget served it: the audio left the homelab")
|
||||||
|
assert.Equal(t, domain.ReconcileSingle, got.Reconciliation.Status)
|
||||||
|
assert.Contains(t, got.Reconciliation.Reason, "both local engines failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsemble_BothFailingWithNoFallbackReportsBothErrors(t *testing.T) {
|
||||||
|
r := newEnsemble("", "", errors.New("pianissimo down"), errors.New("whisper down"), false, &verdictAdj{})
|
||||||
|
|
||||||
|
_, err := r.e.Transcribe(context.Background(), "/a.wav")
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "pianissimo down")
|
||||||
|
assert.Contains(t, err.Error(), "whisper down")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsemble_ACancelledJobNeverReachesTheCloud(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
r := newEnsemble("", "", context.Canceled, context.Canceled, true, &verdictAdj{})
|
||||||
|
|
||||||
|
_, err := r.e.Transcribe(ctx, "/a.wav")
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, context.Canceled)
|
||||||
|
assert.NotContains(t, *r.calls, "berget/kb-whisper", "a cancelled job must not ship its audio to a cloud endpoint as a last act")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsemble_AModelThatIsDownGivesTheBaseTextUnreconciledNotAFailedJob(t *testing.T) {
|
||||||
|
adj := &verdictAdj{err: errors.New("gateway down")}
|
||||||
|
// Four separate disagreements, each separated by two shared words, so the
|
||||||
|
// breaker trips after three consecutive failures.
|
||||||
|
r := newEnsemble("a1 s t b1 s t c1 s t d1", "a2 s t b2 s t c2 s t d2", nil, nil, false, adj)
|
||||||
|
|
||||||
|
got, err := r.e.Transcribe(context.Background(), "/a.wav")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "a1 s t b1 s t c1 s t d1", got.Text)
|
||||||
|
assert.Equal(t, domain.ReconcileSkipped, got.Reconciliation.Status)
|
||||||
|
assert.Equal(t, 3, adj.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cancelling is a backend that cancels the job's context while it runs, the way a
|
||||||
|
// shutdown arrives in the middle of a long transcription.
|
||||||
|
type cancelling struct {
|
||||||
|
name string
|
||||||
|
cancel context.CancelFunc
|
||||||
|
calls *[]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cancelling) Name() string { return c.name }
|
||||||
|
func (c *cancelling) Transcribe(_ context.Context, _ string) (domain.Transcription, error) {
|
||||||
|
*c.calls = append(*c.calls, c.name)
|
||||||
|
c.cancel()
|
||||||
|
return domain.Transcription{}, context.Canceled
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsemble_AShutdownDuringTheFirstEngineStopsBeforeTheSecondOrTheCloud(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
calls := &[]string{}
|
||||||
|
base := &cancelling{name: "iguana/pianissimo", cancel: cancel, calls: calls}
|
||||||
|
alt := &orderedBackend{name: "iguana/kb-whisper", text: "alt", calls: calls}
|
||||||
|
fb := &orderedBackend{name: "berget/kb-whisper", text: "fb", calls: calls}
|
||||||
|
|
||||||
|
_, err := transcribe.NewEnsemble(base, alt, fb, &verdictAdj{}, ensembleOpts).Transcribe(ctx, "/a.wav")
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, context.Canceled)
|
||||||
|
assert.Equal(t, []string{"iguana/pianissimo"}, *calls, "no second engine and no cloud once the job is cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsemble_AShutdownDuringTheSecondEngineIsAnErrorNotASingleEngineSuccess(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
calls := &[]string{}
|
||||||
|
base := &orderedBackend{name: "iguana/pianissimo", text: "base", calls: calls}
|
||||||
|
alt := &cancelling{name: "iguana/kb-whisper", cancel: cancel, calls: calls}
|
||||||
|
|
||||||
|
got, err := transcribe.NewEnsemble(base, alt, nil, &verdictAdj{}, ensembleOpts).Transcribe(ctx, "/a.wav")
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, context.Canceled, "a cancelled job must not be recorded as 'kb-whisper failed'")
|
||||||
|
assert.Empty(t, got.Text)
|
||||||
|
}
|
||||||
@@ -214,7 +214,10 @@ type UploadedTranscript struct {
|
|||||||
// HadVideo records that the uploaded file carried a video track. Only its audio
|
// HadVideo records that the uploaded file carried a video track. Only its audio
|
||||||
// was used and the video was discarded; the page says so. It is set at upload
|
// was used and the video was discarded; the page says so. It is set at upload
|
||||||
// and never cleared (ADR-033).
|
// and never cleared (ADR-033).
|
||||||
HadVideo bool
|
HadVideo bool
|
||||||
CreatedAt time.Time
|
// Reconciliation is how a merged transcript came about, or nil for a single
|
||||||
FinishedAt time.Time // zero until the job reaches done or failed
|
// engine (ADR-034). Set by Get, not by List.
|
||||||
|
Reconciliation *Reconciliation
|
||||||
|
CreatedAt time.Time
|
||||||
|
FinishedAt time.Time // zero until the job reaches done or failed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package web
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -271,3 +273,26 @@ func (a *App) uploadChatView(u domain.UploadedTranscript, model string, history
|
|||||||
Subject: "recording",
|
Subject: "recording",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxSpotsShown is how many contested spots the page lists. The stored list holds up to
|
||||||
|
// 200 and the counts are exact; a page of two hundred "check this" items is not a page
|
||||||
|
// anyone reads, so the rest are counted, not listed.
|
||||||
|
const maxSpotsShown = 50
|
||||||
|
|
||||||
|
// minuteMark renders where in the recording a spot is, as the nearest minute.
|
||||||
|
func minuteMark(seconds float64) string {
|
||||||
|
return fmt.Sprintf("~%d min", int(math.Round(seconds/60)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// shownSpots is the part of the contested list the page renders.
|
||||||
|
func shownSpots(r *domain.Reconciliation) []domain.ContestedSpot {
|
||||||
|
if len(r.Contested) > maxSpotsShown {
|
||||||
|
return r.Contested[:maxSpotsShown]
|
||||||
|
}
|
||||||
|
return r.Contested
|
||||||
|
}
|
||||||
|
|
||||||
|
// moreSpots is how many spots to check are not listed, from the exact counts.
|
||||||
|
func moreSpots(r *domain.Reconciliation) int {
|
||||||
|
return max(0, r.Changed+r.Unsure-len(shownSpots(r)))
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
@@ -65,6 +66,7 @@ templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool) {
|
|||||||
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>
|
||||||
|
@reconciliationNote(u.Reconciliation)
|
||||||
if chatEnabled {
|
if chatEnabled {
|
||||||
<p><a class="btn" href={ templ.SafeURL("/uploads/" + u.ID + "/chat") }>Ask about this recording</a></p>
|
<p><a class="btn" href={ templ.SafeURL("/uploads/" + u.ID + "/chat") }>Ask about this recording</a></p>
|
||||||
}
|
}
|
||||||
@@ -123,3 +125,48 @@ templ videoNotice(hadVideo bool) {
|
|||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reconciliationNote says what was done when two engines transcribed the audio
|
||||||
|
// (ADR-034), and never claims a second opinion that was not there: nil means a single
|
||||||
|
// engine and renders nothing. Spots the model changed or was unsure about are listed
|
||||||
|
// with a minute mark and both readings so the user can check them against the audio.
|
||||||
|
templ reconciliationNote(r *domain.Reconciliation) {
|
||||||
|
if r != nil {
|
||||||
|
switch r.Status {
|
||||||
|
case domain.ReconcileDone:
|
||||||
|
<div class="reconciliation">
|
||||||
|
<p>
|
||||||
|
Reconciled from two transcripts ({ r.Base } and { r.Alt }):
|
||||||
|
{ strconv.Itoa(r.Disputes) } differences,
|
||||||
|
{ strconv.Itoa(r.AutoResolved) } settled automatically,
|
||||||
|
{ strconv.Itoa(r.Adjudicated) } decided by the model ({ r.Reconciler }),
|
||||||
|
{ strconv.Itoa(r.Unsure) } uncertain.
|
||||||
|
</p>
|
||||||
|
if len(r.Contested) > 0 {
|
||||||
|
<details>
|
||||||
|
<summary>Spots to check against the audio</summary>
|
||||||
|
<ul>
|
||||||
|
for _, c := range shownSpots(r) {
|
||||||
|
<li>
|
||||||
|
{ minuteMark(c.Seconds) }: { c.Base } / { c.Alt } —
|
||||||
|
if c.Unsure {
|
||||||
|
<span class="chip chip-warn">uncertain</span> kept { c.Used }
|
||||||
|
} else {
|
||||||
|
<span class="chip">changed</span> used { c.Used }
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
if moreSpots(r) > 0 {
|
||||||
|
<p class="muted">and { strconv.Itoa(moreSpots(r)) } more not listed</p>
|
||||||
|
}
|
||||||
|
</details>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
case domain.ReconcileSingle:
|
||||||
|
<p class="muted reconciliation">Only one engine produced this transcript ({ r.Reason }), so there was no second opinion to reconcile it with.</p>
|
||||||
|
case domain.ReconcileSkipped:
|
||||||
|
<p class="muted reconciliation">Two transcripts were made but could not be reconciled ({ r.Reason }); the text shown is { r.Base }'s, unreconciled.</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package web_test
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -355,3 +356,107 @@ func TestUploadPost_ARejectionIsLoggedWithItsReasonAndNeverTheFilenameOrTitle(t
|
|||||||
assert.NotContains(t, logged, "Secret project notes", "the title is private content")
|
assert.NotContains(t, logged, "Secret project notes", "the title is private content")
|
||||||
assert.NotContains(t, logged, "client-acme-roadmap", "the filename is private content")
|
assert.NotContains(t, logged, "client-acme-roadmap", "the filename is private content")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setReconciliation(t *testing.T, id, json string) {
|
||||||
|
t.Helper()
|
||||||
|
_, err := rawPool(t).Exec(context.Background(), `UPDATE uploaded_transcripts SET reconciliation = $2::jsonb WHERE id = $1`, id, json)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reconciledJSON = `{"status":"reconciled","base":"iguana/pianissimo","alt":"iguana/kb-whisper","reconciler":"iguana/gemma4-26b",
|
||||||
|
"disputes":380,"auto_resolved":90,"adjudicated":285,"changed":120,"unsure":5,"errors":0,
|
||||||
|
"contested":[
|
||||||
|
{"seconds":2210.5,"base":"SEB","alt":"SCB","used":"alt","unsure":false},
|
||||||
|
{"seconds":3400,"base":"T+1","alt":"en T","used":"base","unsure":true}]}`
|
||||||
|
|
||||||
|
func TestUploadStatus_ReconciledSaysWhatWasDoneInPlainWords(t *testing.T) {
|
||||||
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||||
|
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
|
||||||
|
setReconciliation(t, id, reconciledJSON)
|
||||||
|
|
||||||
|
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
|
||||||
|
|
||||||
|
assert.Contains(t, b, "Reconciled from two transcripts")
|
||||||
|
assert.Contains(t, b, "iguana/pianissimo")
|
||||||
|
assert.Contains(t, b, "iguana/kb-whisper")
|
||||||
|
assert.Contains(t, b, "iguana/gemma4-26b", "who decided is part of the account")
|
||||||
|
assert.Contains(t, b, "380 differences")
|
||||||
|
assert.Contains(t, b, "90 settled automatically")
|
||||||
|
assert.Contains(t, b, "285 decided by the model")
|
||||||
|
assert.Contains(t, b, "5 uncertain")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadStatus_ListsTheContestedSpotsWithAMinuteMarkBothReadingsAndWhichWasUsed(t *testing.T) {
|
||||||
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||||
|
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
|
||||||
|
setReconciliation(t, id, reconciledJSON)
|
||||||
|
|
||||||
|
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
|
||||||
|
|
||||||
|
assert.Contains(t, b, "~37 min", "2210.5 s")
|
||||||
|
assert.Contains(t, b, "SEB")
|
||||||
|
assert.Contains(t, b, "SCB")
|
||||||
|
assert.Contains(t, b, "~57 min", "3400 s")
|
||||||
|
assert.Contains(t, b, "T+1")
|
||||||
|
assert.Contains(t, b, "en T")
|
||||||
|
assert.Contains(t, b, "uncertain", "an unsure spot is marked so the reader knows to check it")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadStatus_ASingleEngineTranscriptDoesNotClaimASecondOpinion(t *testing.T) {
|
||||||
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||||
|
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
|
||||||
|
|
||||||
|
assert.NotContains(t, body(t, do(t, app, get("/uploads/"+id+"/status"))), "Reconciled", "no reconciliation, no claim")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadStatus_WhenOnlyOneEngineWorkedItSaysWhyTheSecondOpinionIsMissing(t *testing.T) {
|
||||||
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||||
|
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
|
||||||
|
setReconciliation(t, id, `{"status":"single_engine","reason":"iguana/pianissimo failed: wedged","base":"iguana/pianissimo","alt":"iguana/kb-whisper","reconciler":"iguana/gemma4-26b"}`)
|
||||||
|
|
||||||
|
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
|
||||||
|
|
||||||
|
assert.Contains(t, b, "Only one engine")
|
||||||
|
assert.Contains(t, b, "iguana/pianissimo failed")
|
||||||
|
assert.NotContains(t, b, "Reconciled from two")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadStatus_WhenTheModelWasUnavailableItSaysTheTextIsUnreconciled(t *testing.T) {
|
||||||
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||||
|
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
|
||||||
|
setReconciliation(t, id, `{"status":"skipped","reason":"the model failed 3 times in a row: gateway down","base":"iguana/pianissimo","alt":"iguana/kb-whisper","reconciler":"iguana/gemma4-26b","disputes":380,"errors":3}`)
|
||||||
|
|
||||||
|
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
|
||||||
|
|
||||||
|
assert.Contains(t, b, "could not be reconciled")
|
||||||
|
assert.Contains(t, b, "iguana/pianissimo", "which engine's text is being shown")
|
||||||
|
assert.Contains(t, b, "gateway down")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadStatus_ContestedReadingsAreEscaped(t *testing.T) {
|
||||||
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||||
|
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
|
||||||
|
setReconciliation(t, id, `{"status":"reconciled","base":"a","alt":"b","reconciler":"c","disputes":1,"unsure":1,
|
||||||
|
"contested":[{"seconds":60,"base":"<script>alert(1)</script>","alt":"ok","used":"base","unsure":true}]}`)
|
||||||
|
|
||||||
|
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
|
||||||
|
|
||||||
|
assert.NotContains(t, b, "<script>alert(1)</script>", "a reading is words from a recording, never markup")
|
||||||
|
assert.Contains(t, b, "<script>")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadStatus_AVeryLongListOfContestedSpotsIsShortenedWithItsTrueCount(t *testing.T) {
|
||||||
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||||
|
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
|
||||||
|
var spots []string
|
||||||
|
for i := 0; i < 120; i++ {
|
||||||
|
spots = append(spots, fmt.Sprintf(`{"seconds":%d,"base":"b%d","alt":"a%d","used":"base","unsure":true}`, i*10, i, i))
|
||||||
|
}
|
||||||
|
setReconciliation(t, id, `{"status":"reconciled","base":"a","alt":"b","reconciler":"c","disputes":400,"unsure":120,"contested":[`+strings.Join(spots, ",")+`]}`)
|
||||||
|
|
||||||
|
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
|
||||||
|
|
||||||
|
assert.Contains(t, b, "b0", "the first spots are shown")
|
||||||
|
assert.NotContains(t, b, ">b119<", "the page does not render every one")
|
||||||
|
assert.Contains(t, b, "70 more", "120 stored, 50 shown")
|
||||||
|
}
|
||||||
|
|||||||
+318
-18
@@ -5,11 +5,14 @@ package web
|
|||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
|
|
||||||
import "github.com/a-h/templ"
|
import (
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
"github.com/a-h/templ"
|
||||||
|
templruntime "github.com/a-h/templ/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
@@ -57,7 +60,7 @@ func UploadsPage(v uploadsView) templ.Component {
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d MB", v.Limits.MaxBytes>>20))
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d MB", v.Limits.MaxBytes>>20))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 17, Col: 67}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 18, Col: 67}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -70,7 +73,7 @@ func UploadsPage(v uploadsView) templ.Component {
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(uploads.FormatDuration(v.Limits.MaxDuration))
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(uploads.FormatDuration(v.Limits.MaxDuration))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 18, Col: 50}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 19, Col: 50}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -88,7 +91,7 @@ func UploadsPage(v uploadsView) templ.Component {
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(v.Error)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(v.Error)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 22, Col: 57}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 23, Col: 57}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -116,7 +119,7 @@ func UploadsPage(v uploadsView) templ.Component {
|
|||||||
var templ_7745c5c3_Var6 templ.SafeURL
|
var templ_7745c5c3_Var6 templ.SafeURL
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/uploads/" + u.ID))
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/uploads/" + u.ID))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 35, Col: 51}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 36, Col: 51}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -129,7 +132,7 @@ func UploadsPage(v uploadsView) templ.Component {
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(u.Title)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(u.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 35, Col: 63}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 36, Col: 63}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -142,7 +145,7 @@ func UploadsPage(v uploadsView) templ.Component {
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(string(u.Status))
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(string(u.Status))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 36, Col: 45}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 37, Col: 45}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -213,7 +216,7 @@ func UploadPage(u domain.UploadedTranscript, chatEnabled bool) templ.Component {
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(u.Title)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(u.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 50, Col: 16}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 51, Col: 16}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -281,7 +284,7 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool) templ.Component
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))))
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 65, Col: 95}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 66, Col: 95}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -294,7 +297,7 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool) templ.Component
|
|||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(u.Endpoint)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(u.Endpoint)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 66, Col: 48}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 67, Col: 48}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -304,6 +307,10 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool) templ.Component
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
templ_7745c5c3_Err = reconciliationNote(u.Reconciliation).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
if chatEnabled {
|
if chatEnabled {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<p><a class=\"btn\" href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<p><a class=\"btn\" href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -312,7 +319,7 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool) templ.Component
|
|||||||
var templ_7745c5c3_Var15 templ.SafeURL
|
var templ_7745c5c3_Var15 templ.SafeURL
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/uploads/" + u.ID + "/chat"))
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/uploads/" + u.ID + "/chat"))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 69, Col: 73}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 71, Col: 73}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -330,7 +337,7 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool) templ.Component
|
|||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(u.Content)
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(u.Content)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 73, Col: 40}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 75, Col: 40}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -356,7 +363,7 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool) templ.Component
|
|||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(uploadFailureMessage(u.ErrorKind))
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(uploadFailureMessage(u.ErrorKind))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 79, Col: 42}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 81, Col: 42}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -374,7 +381,7 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool) templ.Component
|
|||||||
var templ_7745c5c3_Var18 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/uploads/" + u.ID + "/status")
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/uploads/" + u.ID + "/status")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 85, Col: 43}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 87, Col: 43}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -387,7 +394,7 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool) templ.Component
|
|||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue("every " + uploadPollEvery)
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue("every " + uploadPollEvery)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 86, Col: 43}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 88, Col: 43}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -463,7 +470,7 @@ func UploadChatPage(u domain.UploadedTranscript, v chatView) templ.Component {
|
|||||||
var templ_7745c5c3_Var22 string
|
var templ_7745c5c3_Var22 string
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(u.Title)
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(u.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 104, Col: 16}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 106, Col: 16}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -484,7 +491,7 @@ func UploadChatPage(u domain.UploadedTranscript, v chatView) templ.Component {
|
|||||||
var templ_7745c5c3_Var23 templ.SafeURL
|
var templ_7745c5c3_Var23 templ.SafeURL
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/uploads/" + u.ID))
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/uploads/" + u.ID))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 110, Col: 49}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 112, Col: 49}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -539,4 +546,297 @@ func videoNotice(hadVideo bool) templ.Component {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reconciliationNote says what was done when two engines transcribed the audio
|
||||||
|
// (ADR-034), and never claims a second opinion that was not there: nil means a single
|
||||||
|
// engine and renders nothing. Spots the model changed or was unsure about are listed
|
||||||
|
// with a minute mark and both readings so the user can check them against the audio.
|
||||||
|
func reconciliationNote(r *domain.Reconciliation) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var25 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var25 == nil {
|
||||||
|
templ_7745c5c3_Var25 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
if r != nil {
|
||||||
|
switch r.Status {
|
||||||
|
case domain.ReconcileDone:
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"reconciliation\"><p>Reconciled from two transcripts (")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var26 string
|
||||||
|
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(r.Base)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 139, Col: 47}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " and ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var27 string
|
||||||
|
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(r.Alt)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 139, Col: 61}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "): ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var28 string
|
||||||
|
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.Disputes))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 140, Col: 32}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " differences, ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var29 string
|
||||||
|
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.AutoResolved))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 141, Col: 36}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " settled automatically, ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var30 string
|
||||||
|
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.Adjudicated))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 142, Col: 35}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " decided by the model (")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var31 string
|
||||||
|
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reconciler)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 142, Col: 74}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "), ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var32 string
|
||||||
|
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.Unsure))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 143, Col: 30}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " uncertain.</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if len(r.Contested) > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<details><summary>Spots to check against the audio</summary><ul>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, c := range shownSpots(r) {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<li>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var33 string
|
||||||
|
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(minuteMark(c.Seconds))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 151, Col: 33}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ": ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var34 string
|
||||||
|
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(c.Base)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 151, Col: 45}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " / ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var35 string
|
||||||
|
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(c.Alt)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 151, Col: 57}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " — ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if c.Unsure {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<span class=\"chip chip-warn\">uncertain</span> kept ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var36 string
|
||||||
|
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(c.Used)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 153, Col: 70}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<span class=\"chip\">changed</span> used ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var37 string
|
||||||
|
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(c.Used)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 155, Col: 58}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</li>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</ul>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if moreSpots(r) > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<p class=\"muted\">and ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var38 string
|
||||||
|
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(moreSpots(r)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 161, Col: 57}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, " more not listed</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</details>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
case domain.ReconcileSingle:
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<p class=\"muted reconciliation\">Only one engine produced this transcript (")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var39 string
|
||||||
|
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reason)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 167, Col: 88}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "), so there was no second opinion to reconcile it with.</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
case domain.ReconcileSkipped:
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<p class=\"muted reconciliation\">Two transcripts were made but could not be reconciled (")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var40 string
|
||||||
|
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reason)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 169, Col: 101}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "); the text shown is ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var41 string
|
||||||
|
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(r.Base)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 169, Col: 132}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "'s, unreconciled.</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
var _ = templruntime.GeneratedTemplate
|
||||||
|
|||||||
Reference in New Issue
Block a user