Allow checkpoints for empty audio accepted by WhisperX

This commit is contained in:
2026-09-22 00:53:28 +00:00
parent 54d335c820
commit 5dca34e0e0
5 changed files with 76 additions and 9 deletions

View File

@@ -64,6 +64,8 @@ canonical `transcript_raw` output with its path, SHA-256 and size, and the
producer run ID. Paths must be canonical and session-relative, checksums must producer run ID. Paths must be canonical and session-relative, checksums must
be lowercase SHA-256 values, and the record and output producer identities be lowercase SHA-256 values, and the record and output producer identities
must agree. Other stages cannot carry this transcribe-owned state. must agree. Other stages cannot carry this transcribe-owned state.
Audio size is nonnegative; WhisperX determines whether that audio can be
transcribed. Output size remains positive and the transcript must be valid JSON.
Transcribe reconciles this collection against the current prepared audio set Transcribe reconciles this collection against the current prepared audio set
and observable WhisperX semantics. Missing or corrupt regular outputs become and observable WhisperX semantics. Missing or corrupt regular outputs become

View File

@@ -1,6 +1,6 @@
# Transcription Checkpoint Review Remediation # Transcription Checkpoint Review Remediation
Status: Open. Status: Complete.
Reviewed commits `7b0bf99` through `96b4e73` against the speaker-checkpoint and Reviewed commits `7b0bf99` through `96b4e73` against the speaker-checkpoint and
upload-replay roadmap and the original user request. No production changes were upload-replay roadmap and the original user request. No production changes were
@@ -56,7 +56,13 @@ correct rebuilt outputs, and no removed speaker reaching merge.
## 2. Remove the Unrequested Positive Audio-Size Requirement ## 2. Remove the Unrequested Positive Audio-Size Requirement
Priority: P2. Status: Pending. Priority: P2. Status: Complete.
Audio evidence now permits zero size while still rejecting negative values.
Regression tests verify zero-size manifest round trips, successful service
results being checkpointed and reused, and service rejection being propagated
without creating a completion record. Nonempty valid-JSON output validation is
unchanged. Both new success regressions failed before the fix.
Location: `internal/manifest/transcribe_state.go:95`. Location: `internal/manifest/transcribe_state.go:95`.
@@ -90,7 +96,9 @@ ordinary downstream-failure tests.
## Verification After Remediation ## Verification After Remediation
Run focused stage/manifest/application tests, changed-package race tests, and Completed on 2026-09-22 with Go 1.26.8: focused stage/application tests,
the standard repository validation gates from `docs/development.md`. Preserve changed-package race tests, `go test ./...`, `go test -race ./...`,
the existing replay and cancellation regression tests; these findings do not `go vet ./...`, `go build ./...`, `go test ./internal/doccheck`,
require redesigning the HTTP adapter. `go test ./internal/config -run '^TestExamplesLoadAndValidate$'`, and
`git diff --check` all passed. Existing replay and cancellation regression
tests remain intact; the HTTP adapter required no changes.

View File

@@ -18,6 +18,57 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/stage" "gitea.maximumdirect.net/eric/narratio/internal/stage"
) )
func TestExecuteStagesDelegatesEmptyAudioAcceptanceToWhisperX(t *testing.T) {
for _, rejected := range []bool{false, true} {
name := "accepted"
if rejected {
name = "rejected"
}
t.Run(name, func(t *testing.T) {
cfg, store := setupTranscribeRecoveryFixture(t, []string{"alice"})
paths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
if err := os.WriteFile(filepath.Join(paths.AudioDir, "alice.flac"), nil, 0o644); err != nil {
t.Fatal(err)
}
fake := &whisperx.FakeClient{}
if rejected {
fake.Err = errors.New("service rejected audio")
}
transcribe := mustSelectStage(t, "transcribe")
_, err := executeStages(context.Background(), cfg, []stage.Stage{transcribe}, RunOptions{
Env: &Env{ManifestStore: store, WhisperX: fake},
})
if !errors.Is(err, fake.Err) {
t.Fatalf("transcribe error = %v, want %v", err, fake.Err)
}
if len(fake.RequestsSnapshot()) != 1 {
t.Fatal("empty audio was not sent to service")
}
m := loadTranscribeRecoveryManifest(t, store, cfg)
if rejected {
if len(m.Stages["transcribe"].TranscribeSpeakers) != 0 {
t.Fatal("rejected audio was checkpointed")
}
return
}
record, ok := m.Stages["transcribe"].TranscribeSpeakers["alice"]
if !ok || record.AudioSize != 0 {
t.Fatalf("missing zero-size checkpoint: %#v", record)
}
retry := &whisperx.FakeClient{}
summary, err := executeStages(context.Background(), cfg, []stage.Stage{transcribe}, RunOptions{
Env: &Env{ManifestStore: store, WhisperX: retry},
})
if err != nil {
t.Fatal(err)
}
if len(summary.Skipped) != 1 || len(retry.RequestsSnapshot()) != 0 {
t.Fatal("successful empty-audio result was not reused")
}
})
}
}
func TestExecuteStagesRetriesOnlyUncommittedTranscribeSpeakers(t *testing.T) { func TestExecuteStagesRetriesOnlyUncommittedTranscribeSpeakers(t *testing.T) {
cfg, baseStore := setupTranscribeRecoveryFixture(t, []string{"a", "b", "c", "d"}) cfg, baseStore := setupTranscribeRecoveryFixture(t, []string{"a", "b", "c", "d"})
concurrency := 1 concurrency := 1

View File

@@ -92,8 +92,8 @@ func validateTranscribeSpeakerRecord(speakerID string, record TranscribeSpeakerR
if err := validateSHA256("audio_sha256", record.AudioSHA256); err != nil { if err := validateSHA256("audio_sha256", record.AudioSHA256); err != nil {
return err return err
} }
if record.AudioSize <= 0 { if record.AudioSize < 0 {
return fmt.Errorf("audio_size must be positive") return fmt.Errorf("audio_size must not be negative")
} }
if record.Output.Kind != "transcript_raw" { if record.Output.Kind != "transcript_raw" {
return fmt.Errorf("output kind %q must be transcript_raw", record.Output.Kind) return fmt.Errorf("output kind %q must be transcript_raw", record.Output.Kind)

View File

@@ -12,6 +12,8 @@ import (
func TestTranscribeSpeakerStateRoundTripsPopulatedAndVersionedEmptyCollections(t *testing.T) { func TestTranscribeSpeakerStateRoundTripsPopulatedAndVersionedEmptyCollections(t *testing.T) {
now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC) now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC)
store := &LocalStore{} store := &LocalStore{}
zeroAudio := validTranscribeSpeakerRecord("Alice Table 1")
zeroAudio.AudioSize = 0
for _, test := range []struct { for _, test := range []struct {
name string name string
@@ -24,6 +26,7 @@ func TestTranscribeSpeakerStateRoundTripsPopulatedAndVersionedEmptyCollections(t
}, },
}, },
{name: "versioned empty", records: map[string]TranscribeSpeakerRecord{}}, {name: "versioned empty", records: map[string]TranscribeSpeakerRecord{}},
{name: "zero size audio", records: map[string]TranscribeSpeakerRecord{"Alice Table 1": zeroAudio}},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
m := New("session", now) m := New("session", now)
@@ -49,6 +52,9 @@ func TestTranscribeSpeakerStateRoundTripsPopulatedAndVersionedEmptyCollections(t
} }
if len(test.records) != 0 { if len(test.records) != 0 {
got := stage.TranscribeSpeakers["Alice Table 1"] got := stage.TranscribeSpeakers["Alice Table 1"]
if got.AudioSize != test.records["Alice Table 1"].AudioSize {
t.Fatalf("audio size = %d, want %d", got.AudioSize, test.records["Alice Table 1"].AudioSize)
}
if got.AudioPath != "inputs/audio/Alice Table 1.flac" || got.Output.LocalPath != "transcripts/raw/Alice Table 1.json" { if got.AudioPath != "inputs/audio/Alice Table 1.flac" || got.Output.LocalPath != "transcripts/raw/Alice Table 1.json" {
t.Fatalf("round-tripped record = %#v", got) t.Fatalf("round-tripped record = %#v", got)
} }
@@ -99,7 +105,7 @@ func TestValidateTranscribeSpeakerCollectionRejectsMalformedEvidence(t *testing.
{name: "unsafe audio path", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.AudioPath = "../alice.flac" }, want: "audio_path is unsafe"}, {name: "unsafe audio path", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.AudioPath = "../alice.flac" }, want: "audio_path is unsafe"},
{name: "noncanonical audio path", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.AudioPath = "inputs/audio/../alice.flac" }, want: "not canonical"}, {name: "noncanonical audio path", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.AudioPath = "inputs/audio/../alice.flac" }, want: "not canonical"},
{name: "bad audio checksum", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.AudioSHA256 = strings.Repeat("G", 64) }, want: "audio_sha256 must be"}, {name: "bad audio checksum", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.AudioSHA256 = strings.Repeat("G", 64) }, want: "audio_sha256 must be"},
{name: "bad audio size", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.AudioSize = 0 }, want: "audio_size must be positive"}, {name: "negative audio size", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.AudioSize = -1 }, want: "audio_size"},
{name: "bad output kind", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.Output.Kind = "transcript" }, want: "must be transcript_raw"}, {name: "bad output kind", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.Output.Kind = "transcript" }, want: "must be transcript_raw"},
{name: "unsafe output path", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.Output.LocalPath = "/tmp/alice.json" }, want: "output local_path is unsafe"}, {name: "unsafe output path", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.Output.LocalPath = "/tmp/alice.json" }, want: "output local_path is unsafe"},
{name: "bad output checksum", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.Output.Checksum = "bad" }, want: "output checksum must be"}, {name: "bad output checksum", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.Output.Checksum = "bad" }, want: "output checksum must be"},